Repository: pyjanitor-devs/pyjanitor
Pull request #1355 (2024-04-30 00:54:11)

Polars `pivot_longer` implementation
# PR Description

Please describe the changes proposed in the pull request:

- `pivot_longer` implementation for polars
-  `pivot_longer_spec` for more granular control


**This PR is related to #1352 .**

<!-- 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.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [x] Add a line to `CHANGELOG.md` 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.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl


ericmjl: <!-- NETLIFY DEPLOY COMMENT GENERATED BY ACTIONS_NETLIFY -->
🚀 Deployed on https://deploy-preview-1355--pyjanitor.netlify.app
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1355?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) Report
Attention: Patch coverage is `96.20253%` with `6 lines` in your changes are missing coverage. Please review.
> Project coverage is 94.14%. Comparing base [(`62c57c6`)](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/commit/62c57c6ad27196a10e017f710ce7d523bc2541ba?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs) to head [(`7a69b54`)](https://app.codecov.io/gh/pyjanitor-devs/pyjanitor/pull/1355?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyjanitor-devs).
> Report is 7 commits behind head on dev.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #1355      +/-   ##
==========================================
- Coverage   94.48%   94.14%   -0.35%     
==========================================
  Files          80       82       +2     
  Lines        4367     4523     +156     
==========================================
+ Hits         4126     4258     +132     
- Misses        241      265      +24     
```

</details>
ericmjl: @samukweku looks like we are getting merge conflicts on this PR. I'm a bit hesitant to edit the files as I'm not 100% sure what the right final state should look like. Could you resolve the conflicts please?
ericmjl: LGTM! Thank you, @samukweku, for the hard work you've been putting in here!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1ff0f0..583b6fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,358 +2,359 @@
 
 ## [Unreleased]
 
--  [ENH] `xlsx_cells` function now supports polars - Issue #1352
--  [ENH] `xlsx_table` function now supports polars - Issue #1352
--  [ENH] Improved performance for non-equi joins when using numba - @samukweku PR #1341
--  [ENH] Added a `clean_names` method for polars - it can be used to clean the column names, or clean column values . Issue #1343
+- [ENH] Improved performance for non-equi joins when using numba - @samukweku PR #1341
+- [ENH] Added a `clean_names` method for polars - it can be used to clean the column names, or clean column values . Issue #1343 @samukweku
+- [ENH] `xlsx_cells` function now supports polars - Issue #1352 @samukweku
+- [ENH] `xlsx_table` function now supports polars - Issue #1352 @samukweku
+- [ENH] Added a `pivot_longer` method, and a `pivot_longer_spec` function for polars - Issue #1352 @samukweku
 
 ## [v0.27.0] - 2024-03-21
 
--   [BUG] Fix logic for groupby in complete. Index support deprecated. Fix deprecation warning for fillna in `complete` PR #1289 @samukweku
--   [ENH] `select` function now supports variable arguments - PR #1288 @samukweku
--   [ENH] `conditional_join` now supports timedelta dtype. - PR #1297 @samukweku
--   [ENH]  `get_join_indices` function added - returns only join indices between two dataframes. Issue #1310 @samukweku
--   [ENH] `explode_index` function added. - Issue #1283
--   [ENH] `conditional_join` now supports timedelta dtype. - PR #1297
--   [ENH] `change_index_dtype` added. - @samukweku Issue #1314
--   [ENH] Add `glue` and `axis` parameters to `collapse_levels`. - Issue #211 @samukweku
--   [ENH] `row_to_names` now supports multiple rows conversion to columns. - @samukweku Issue #1333
--   [ENH] Fix warnings from Pandas. `truncate_datetime` now uses a vectorized option.  -@samukweku #1337
+- [BUG] Fix logic for groupby in complete. Index support deprecated. Fix deprecation warning for fillna in `complete` PR #1289 @samukweku
+- [ENH] `select` function now supports variable arguments - PR #1288 @samukweku
+- [ENH] `conditional_join` now supports timedelta dtype. - PR #1297 @samukweku
+- [ENH] `get_join_indices` function added - returns only join indices between two dataframes. Issue #1310 @samukweku
+- [ENH] `explode_index` function added. - Issue #1283
+- [ENH] `conditional_join` now supports timedelta dtype. - PR #1297
+- [ENH] `change_index_dtype` added. - @samukweku Issue #1314
+- [ENH] Add `glue` and `axis` parameters to `collapse_levels`. - Issue #211 @samukweku
+- [ENH] `row_to_names` now supports multiple rows conversion to columns. - @samukweku Issue #1333
+- [ENH] Fix warnings from Pandas. `truncate_datetime` now uses a vectorized option. -@samukweku #1337
 
 ## [v0.26.0] - 2023-09-18
 
--   [ENH] `clean_names` can now be applied to column values. Issue #995 @samukweku
--   [BUG] Fix ImportError - Issue #1285 @samukweku
+- [ENH] `clean_names` can now be applied to column values. Issue #995 @samukweku
+- [BUG] Fix ImportError - Issue #1285 @samukweku
 
 ## [v0.25.0] - 2023-07-27
 
--   [INF] Replace `pytest.ini` file with `pyproject.toml` file. PR #1204 @Zeroto521
--   [INF] Extract docstrings tests from all tests. PR #1205 @Zeroto521
--   [BUG] Address the `TypeError` when importing v0.24.0 (issue #1201 @xujiboy and @joranbeasley)
--   [INF] Fixed issue with missing PyPI README. PR #1216 @thatlittleboy
--   [INF] Update some `mkdocs` compatibility code. PR #1231 @thatlittleboy
--   [INF] Migrated docstring style from Sphinx to Google for better compatibility with `mkdocstrings`. PR #1235 @thatlittleboy
--   [INF] Prevent selection of chevrons (`>>>`) and outputs in Example code blocks. PR #1237 @thatlittleboy
--   [DEPR] Add deprecation warnings for `process_text`, `rename_column`, `rename_columns`, `filter_on`, `remove_columns`, `fill_direction`. Issue #1045 @samukweku
--   [ENH] `pivot_longer` now supports named groups where `names_pattern` is a regular expression. A dictionary can now be passed to `names_pattern`, and is internally evaluated as a list/tuple of regular expressions. Issue #1209 @samukweku
--   [ENH] Improve selection in `conditional_join`. Issue #1223 @samukweku
--   [ENH] Add `col` class for selecting columns within an expression. Currently limited to use within `conditional_join`. PR #1260 @samukweku.
--   [ENH] Performance improvement for range joins in `conditional_join`, when `use_numba = False`. Performance improvement for equi-join and a range join, when `use_numba = True`, for many to many join with wide ranges. PR #1256, #1267 @samukweku
--   [DEPR] Add deprecation warning for `pivot_wider`. Issue #1045 @samukweku
--   [BUG] Fix string column selection on a MultiIndex. Issue #1265. @samukweku
+- [INF] Replace `pytest.ini` file with `pyproject.toml` file. PR #1204 @Zeroto521
+- [INF] Extract docstrings tests from all tests. PR #1205 @Zeroto521
+- [BUG] Address the `TypeError` when importing v0.24.0 (issue #1201 @xujiboy and @joranbeasley)
+- [INF] Fixed issue with missing PyPI README. PR #1216 @thatlittleboy
+- [INF] Update some `mkdocs` compatibility code. PR #1231 @thatlittleboy
+- [INF] Migrated docstring style from Sphinx to Google for better compatibility with `mkdocstrings`. PR #1235 @thatlittleboy
+- [INF] Prevent selection of chevrons (`>>>`) and outputs in Example code blocks. PR #1237 @thatlittleboy
+- [DEPR] Add deprecation warnings for `process_text`, `rename_column`, `rename_columns`, `filter_on`, `remove_columns`, `fill_direction`. Issue #1045 @samukweku
+- [ENH] `pivot_longer` now supports named groups where `names_pattern` is a regular expression. A dictionary can now be passed to `names_pattern`, and is internally evaluated as a list/tuple of regular expressions. Issue #1209 @samukweku
+- [ENH] Improve selection in `conditional_join`. Issue #1223 @samukweku
+- [ENH] Add `col` class for selecting columns within an expression. Currently limited to use within `conditional_join`. PR #1260 @samukweku.
+- [ENH] Performance improvement for range joins in `conditional_join`, when `use_numba = False`. Performance improvement for equi-join and a range join, when `use_numba = True`, for many to many join with wide ranges. PR #1256, #1267 @samukweku
+- [DEPR] Add deprecation warning for `pivot_wider`. Issue #1045 @samukweku
+- [BUG] Fix string column selection on a MultiIndex. Issue #1265. @samukweku
 
 ## [v0.24.0] - 2022-11-12
 
--   [ENH] Add lazy imports to speed up the time taken to load pyjanitor (part 2)
--   [DOC] Updated developer guide docs.
--   [ENH] Allow column selection/renaming within conditional_join. Issue #1102. Also allow first or last match. Issue #1020 @samukweku.
--   [ENH] New decorator `deprecated_kwargs` for breaking API. #1103 @Zeroto521
--   [ENH] Extend select_columns to support non-string columns. Issue #1105 @samukweku
--   [ENH] Performance improvement for groupby_topk. Issue #1093 @samukweku
--   [ENH] `min_max_scale` drop `old_min` and `old_max` to fit sklearn's method API. Issue #1068 @Zeroto521
--   [ENH] Add `jointly` option for `min_max_scale` support to transform each column values or entire values. Default transform each column, similar behavior to `sklearn.preprocessing.MinMaxScaler`. (Issue #1067, PR #1112, PR #1123) @Zeroto521
--   [INF] Require pyspark minimal version is v3.2.0 to cut duplicates codes. Issue #1110 @Zeroto521
--   [ENH] Add support for extension arrays in `expand_grid`. Issue #1121 @samukweku
--   [ENH] Add `names_expand` and `index_expand` parameters to `pivot_wider` for exposing missing categoricals. Issue #1108 @samukweku
--   [ENH] Add fix for slicing error when selecting columns in `pivot_wider`. Issue #1134 @samukweku
--   [ENH] `dropna` parameter added to `pivot_longer`. Issue #1132 @samukweku
--   [INF] Update `mkdocstrings` version and to fit its new coming features. PR #1138 @Zeroto521
--   [BUG] Force `math.softmax` returning `Series`. PR #1139 @Zeroto521
--   [INF] Set independent environment for building documentation. PR #1141 @Zeroto521
--   [DOC] Add local documentation preview via github action artifact. PR #1149 @Zeroto521
--   [ENH] Enable `encode_categorical` handle 2 (or more ) dimensions array. PR #1153 @Zeroto521
--   [TST] Fix testcases failing on Window. Issue #1160 @Zeroto521, and @samukweku
--   [INF] Cancel old workflow runs via Github Action `concurrency`. PR #1161 @Zeroto521
--   [ENH] Faster computation for non-equi join, with a numba engine. Speed improvement for left/right joins when `sort_by_appearance` is False. Issue #1102 @samukweku
--   [BUG] Avoid `change_type` mutating original `DataFrame`. PR #1162 @Zeroto521
--   [ENH] The parameter `column_name` of `change_type` totally supports inputing multi-column now. #1163 @Zeroto521
--   [ENH] Fix error when `sort_by_appearance=True` is combined with `dropna=True`. Issue #1168 @samukweku
--   [ENH] Add explicit default parameter to `case_when` function. Issue #1159 @samukweku
--   [BUG] pandas 1.5.x `_MergeOperation` doesn't have `copy` keyword anymore. Issue #1174 @Zeroto521
--   [ENH] `select_rows` function added for flexible row selection. Generic `select` function added as well. Add support for MultiIndex selection via dictionary. Issue #1124 @samukweku
--   [TST] Compat with macos and window, to fix `FailedHealthCheck` Issue #1181 @Zeroto521
--   [INF] Merge two docs CIs (`docs-preview.yml` and `docs.yml`) to one. And add `documentation` pytest mark. PR #1183 @Zeroto521
--   [INF] Merge `codecov.yml` (only works for the dev branch pushing event) into `tests.yml` (only works for PR event). PR #1185 @Zeroto521
--   [TST] Fix failure for test/timeseries/test_fill_missing_timestamp. Issue #1184 @samukweku
--   [BUG] Import `DataDescription` to fix: `AttributeError: 'DataFrame' object has no attribute 'data_description'`. PR #1191 @Zeroto521
+- [ENH] Add lazy imports to speed up the time taken to load pyjanitor (part 2)
+- [DOC] Updated developer guide docs.
+- [ENH] Allow column selection/renaming within conditional_join. Issue #1102. Also allow first or last match. Issue #1020 @samukweku.
+- [ENH] New decorator `deprecated_kwargs` for breaking API. #1103 @Zeroto521
+- [ENH] Extend select_columns to support non-string columns. Issue #1105 @samukweku
+- [ENH] Performance improvement for groupby_topk. Issue #1093 @samukweku
+- [ENH] `min_max_scale` drop `old_min` and `old_max` to fit sklearn's method API. Issue #1068 @Zeroto521
+- [ENH] Add `jointly` option for `min_max_scale` support to transform each column values or entire values. Default transform each column, similar behavior to `sklearn.preprocessing.MinMaxScaler`. (Issue #1067, PR #1112, PR #1123) @Zeroto521
+- [INF] Require pyspark minimal version is v3.2.0 to cut duplicates codes. Issue #1110 @Zeroto521
+- [ENH] Add support for extension arrays in `expand_grid`. Issue #1121 @samukweku
+- [ENH] Add `names_expand` and `index_expand` parameters to `pivot_wider` for exposing missing categoricals. Issue #1108 @samukweku
+- [ENH] Add fix for slicing error when selecting columns in `pivot_wider`. Issue #1134 @samukweku
+- [ENH] `dropna` parameter added to `pivot_longer`. Issue #1132 @samukweku
+- [INF] Update `mkdocstrings` version and to fit its new coming features. PR #1138 @Zeroto521
+- [BUG] Force `math.softmax` returning `Series`. PR #1139 @Zeroto521
+- [INF] Set independent environment for building documentation. PR #1141 @Zeroto521
+- [DOC] Add local documentation preview via github action artifact. PR #1149 @Zeroto521
+- [ENH] Enable `encode_categorical` handle 2 (or more ) dimensions array. PR #1153 @Zeroto521
+- [TST] Fix testcases failing on Window. Issue #1160 @Zeroto521, and @samukweku
+- [INF] Cancel old workflow runs via Github Action `concurrency`. PR #1161 @Zeroto521
+- [ENH] Faster computation for non-equi join, with a numba engine. Speed improvement for left/right joins when `sort_by_appearance` is False. Issue #1102 @samukweku
+- [BUG] Avoid `change_type` mutating original `DataFrame`. PR #1162 @Zeroto521
+- [ENH] The parameter `column_name` of `change_type` totally supports inputing multi-column now. #1163 @Zeroto521
+- [ENH] Fix error when `sort_by_appearance=True` is combined with `dropna=True`. Issue #1168 @samukweku
+- [ENH] Add explicit default parameter to `case_when` function. Issue #1159 @samukweku
+- [BUG] pandas 1.5.x `_MergeOperation` doesn't have `copy` keyword anymore. Issue #1174 @Zeroto521
+- [ENH] `select_rows` function added for flexible row selection. Generic `select` function added as well. Add support for MultiIndex selection via dictionary. Issue #1124 @samukweku
+- [TST] Compat with macos and window, to fix `FailedHealthCheck` Issue #1181 @Zeroto521
+- [INF] Merge two docs CIs (`docs-preview.yml` and `docs.yml`) to one. And add `documentation` pytest mark. PR #1183 @Zeroto521
+- [INF] Merge `codecov.yml` (only works for the dev branch pushing event) into `tests.yml` (only works for PR event). PR #1185 @Zeroto521
+- [TST] Fix failure for test/timeseries/test_fill_missing_timestamp. Issue #1184 @samukweku
+- [BUG] Import `DataDescription` to fix: `AttributeError: 'DataFrame' object has no attribute 'data_description'`. PR #1191 @Zeroto521
 
 ## [v0.23.1] - 2022-05-03
 
--   [DOC] Updated `fill.py` and `update_where.py` documentation with working examples.
--   [ENH] Deprecate `num_bins` from `bin_numeric` in favour of `bins`, and allow generic `**kwargs` to be passed into `pd.cut`. Issue #969. @thatlittleboy
--   [ENH] Fix `concatenate_columns` not working on category inputs @zbarry
--   [INF] Simplify CI system @ericmjl
--   [ENH] Added "read_commandline" function to janitor.io @BaritoneBeard
--   [BUG] Fix bug with the complement parameter of `filter_on`. Issue #988. @thatlittleboy
--   [ENH] Add `xlsx_table`, for reading tables from an Excel sheet. @samukweku
--   [ENH] minor improvements for conditional_join; equality only joins are no longer supported; there has to be at least one non-equi join present. @samukweku
--   [BUG] `sort_column_value_order` no longer mutates original dataframe.
--   [BUG] Extend `fill_empty`'s `column_names` type range. Issue #998. @Zeroto521
--   [BUG] Removed/updated error-inducing default arguments in `row_to_names` (#1004) and `round_to_fraction` (#1005). @thatlittleboy
--   [ENH] `patterns` deprecated in favour of importing `re.compile`. #1007 @samukweku
--   [ENH] Changes to kwargs in `encode_categorical`, where the values can either be a string or a 1D array. #1021 @samukweku
--   [ENH] Add `fill_value` and `explicit` parameters to the `complete` function. #1019 @samukweku
--   [ENH] Performance improvement for `expand_grid`. @samukweku
--   [BUG] Make `factorize_columns` (PR #1028) and `truncate_datetime_dataframe` (PR #1040) functions non-mutating. @thatlittleboy
--   [BUG] Fix SettingWithCopyWarning and other minor bugs when using `truncate_datetime_dataframe`, along with further performance improvements (PR #1040). @thatlittleboy
--   [ENH] Performance improvement for `conditional_join`. @samukweku
--   [ENH] Multiple `.value` is now supported in `pivot_longer`. Multiple values_to is also supported, when names_pattern is a list or tuple. `names_transform` parameter added, for efficient dtype transformation of unpivoted columns. #1034, #1048, #1051 @samukweku
--   [ENH] Add `xlsx_cells` for reading a spreadsheet as a table of individual cells. #929 @samukweku.
--   [ENH] Let `filter_string` suit parameters of `Series.str.contains` Issue #1003 and #1047. @Zeroto521
--   [ENH] `names_glue` in `pivot_wider` now takes a string form, using str.format_map under the hood. `levels_order` is also deprecated. @samukweku
--   [BUG] Fixed bug in `transform_columns` which ignored the `column_names` specification when `new_column_names` dictionary was provided as an argument, issue #1063. @thatlittleboy
--   [BUG] `count_cumulative_unique` no longer modifies the column being counted in the output when `case_sensitive` argument is set to False, issue #1065. @thatlittleboy
--   [BUG] Fix for gcc missing error in dev container
--   [DOC] Added a step in the dev guide to install `Remote Container` in VS Code. @ashenafiyb
--   [DOC] Convert `expand_column` and `find_replace` code examples to doctests, issue #972. @gahjelle
--   [DOC] Convert `expand_column` code examples to doctests, issue #972. @gahjelle
--   [DOC] Convert `get_dupes` code examples to doctests, issue #972. @ethompsy
--   [DOC] Convert `engineering` code examples to doctests, issue #972 @ashenafiyb
--   [DOC] Convert `groupby_topk` code examples to doctests, issue #972. @ethompsy
--   [DOC] Add doctests to `math`, issue #972. @gahjelle
--   [DOC] Add doctests to `math` and `ml`, issue #972. @gahjelle
--   [DOC] Add doctests to `math`, `ml`, and `xarray`, issue #972. @gahjelle
+- [DOC] Updated `fill.py` and `update_where.py` documentation with working examples.
+- [ENH] Deprecate `num_bins` from `bin_numeric` in favour of `bins`, and allow generic `**kwargs` to be passed into `pd.cut`. Issue #969. @thatlittleboy
+- [ENH] Fix `concatenate_columns` not working on category inputs @zbarry
+- [INF] Simplify CI system @ericmjl
+- [ENH] Added "read_commandline" function to janitor.io @BaritoneBeard
+- [BUG] Fix bug with the complement parameter of `filter_on`. Issue #988. @thatlittleboy
+- [ENH] Add `xlsx_table`, for reading tables from an Excel sheet. @samukweku
+- [ENH] minor improvements for conditional_join; equality only joins are no longer supported; there has to be at least one non-equi join present. @samukweku
+- [BUG] `sort_column_value_order` no longer mutates original dataframe.
+- [BUG] Extend `fill_empty`'s `column_names` type range. Issue #998. @Zeroto521
+- [BUG] Removed/updated error-inducing default arguments in `row_to_names` (#1004) and `round_to_fraction` (#1005). @thatlittleboy
+- [ENH] `patterns` deprecated in favour of importing `re.compile`. #1007 @samukweku
+- [ENH] Changes to kwargs in `encode_categorical`, where the values can either be a string or a 1D array. #1021 @samukweku
+- [ENH] Add `fill_value` and `explicit` parameters to the `complete` function. #1019 @samukweku
+- [ENH] Performance improvement for `expand_grid`. @samukweku
+- [BUG] Make `factorize_columns` (PR #1028) and `truncate_datetime_dataframe` (PR #1040) functions non-mutating. @thatlittleboy
+- [BUG] Fix SettingWithCopyWarning and other minor bugs when using `truncate_datetime_dataframe`, along with further performance improvements (PR #1040). @thatlittleboy
+- [ENH] Performance improvement for `conditional_join`. @samukweku
+- [ENH] Multiple `.value` is now supported in `pivot_longer`. Multiple values_to is also supported, when names_pattern is a list or tuple. `names_transform` parameter added, for efficient dtype transformation of unpivoted columns. #1034, #1048, #1051 @samukweku
+- [ENH] Add `xlsx_cells` for reading a spreadsheet as a table of individual cells. #929 @samukweku.
+- [ENH] Let `filter_string` suit parameters of `Series.str.contains` Issue #1003 and #1047. @Zeroto521
+- [ENH] `names_glue` in `pivot_wider` now takes a string form, using str.format_map under the hood. `levels_order` is also deprecated. @samukweku
+- [BUG] Fixed bug in `transform_columns` which ignored the `column_names` specification when `new_column_names` dictionary was provided as an argument, issue #1063. @thatlittleboy
+- [BUG] `count_cumulative_unique` no longer modifies the column being counted in the output when `case_sensitive` argument is set to False, issue #1065. @thatlittleboy
+- [BUG] Fix for gcc missing error in dev container
+- [DOC] Added a step in the dev guide to install `Remote Container` in VS Code. @ashenafiyb
+- [DOC] Convert `expand_column` and `find_replace` code examples to doctests, issue #972. @gahjelle
+- [DOC] Convert `expand_column` code examples to doctests, issue #972. @gahjelle
+- [DOC] Convert `get_dupes` code examples to doctests, issue #972. @ethompsy
+- [DOC] Convert `engineering` code examples to doctests, issue #972 @ashenafiyb
+- [DOC] Convert `groupby_topk` code examples to doctests, issue #972. @ethompsy
+- [DOC] Add doctests to `math`, issue #972. @gahjelle
+- [DOC] Add doctests to `math` and `ml`, issue #972. @gahjelle
+- [DOC] Add doctests to `math`, `ml`, and `xarray`, issue #972. @gahjelle
 
 ## [v0.22.0] - 2021-11-21
 
--   [BUG] Fix conditional join issue for multiple conditions, where pd.eval fails to evaluate if numexpr is installed. #898 @samukweku
--   [ENH] Added `case_when` to handle multiple conditionals and replacement values. Issue #736. @robertmitchellv
--   [ENH] Deprecate `new_column_names` and `merge_frame` from `process_text`. Only existing columns are supported. @samukweku
--   [ENH] `complete` uses `pd.merge` internally, providing a simpler logic, with some speed improvements in certain cases over `pd.reindex`. @samukweku
--   [ENH] `expand_grid` returns a MultiIndex DataFrame, allowing the user to decide how to manipulate the columns. @samukweku
--   [INF] Simplify a bit linting, use pre-commit as the CI linting checker. @Zeroto521
--   [ENH] Fix bug in `pivot_longer` for wrong output when `names_pattern` is a sequence with a single value. Issue #885 @samukweku
--   [ENH] Deprecate `aggfunc` from `pivot_wider`; aggregation can be chained with pandas' `groupby`.
--   [ENH] `As_Categorical` deprecated from `encode_categorical`; a tuple of `(categories, order)` suffices for \*\*kwargs. @samukweku
--   [ENH] Deprecate `names_sort` from `pivot_wider`.@samukweku
--   [ENH] Add `softmax` to `math` module. Issue #902. @loganthomas
+- [BUG] Fix conditional join issue for multiple conditions, where pd.eval fails to evaluate if numexpr is installed. #898 @samukweku
+- [ENH] Added `case_when` to handle multiple conditionals and replacement values. Issue #736. @robertmitchellv
+- [ENH] Deprecate `new_column_names` and `merge_frame` from `process_text`. Only existing columns are supported. @samukweku
+- [ENH] `complete` uses `pd.merge` internally, providing a simpler logic, with some speed improvements in certain cases over `pd.reindex`. @samukweku
+- [ENH] `expand_grid` returns a MultiIndex DataFrame, allowing the user to decide how to manipulate the columns. @samukweku
+- [INF] Simplify a bit linting, use pre-commit as the CI linting checker. @Zeroto521
+- [ENH] Fix bug in `pivot_longer` for wrong output when `names_pattern` is a sequence with a single value. Issue #885 @samukweku
+- [ENH] Deprecate `aggfunc` from `pivot_wider`; aggregation can be chained with pandas' `groupby`.
+- [ENH] `As_Categorical` deprecated from `encode_categorical`; a tuple of `(categories, order)` suffices for \*\*kwargs. @samukweku
+- [ENH] Deprecate `names_sort` from `pivot_wider`.@samukweku
+- [ENH] Add `softmax` to `math` module. Issue #902. @loganthomas
 
 ## [v0.21.2] - 2021-09-01
 
--   [ENH] Fix warning message in `coalesce`, from bfill/fill;`coalesce` now uses variable arguments. Issue #882 @samukweku
--   [INF] Add SciPy as explicit dependency in `base.in`. Issue #895 @ericmjl
+- [ENH] Fix warning message in `coalesce`, from bfill/fill;`coalesce` now uses variable arguments. Issue #882 @samukweku
+- [INF] Add SciPy as explicit dependency in `base.in`. Issue #895 @ericmjl
 
 ## [v0.21.1] - 2021-08-29
 
--   [DOC] Fix references and broken links in AUTHORS.rst. @loganthomas
--   [DOC] Updated Broken links in the README and contributing docs. @nvamsikrishna05
--   [INF] Update pre-commit hooks and remove mutable references. Issue #844. @loganthomas
--   [INF] Add GitHub Release pointer to auto-release script. Issue #818. @loganthomas
--   [INF] Updated black version in github actions code-checks to match pre-commit hooks. @nvamsikrishna05
--   [ENH] Add reset_index flag to row_to_names function. @fireddd
--   [ENH] Updated `label_encode` to use pandas factorize instead of scikit-learn LabelEncoder. @nvamsikrishna05
--   [INF] Removed the scikit-learn package from the dependencies from environment-dev.yml and base.in files. @nvamsikrishna05
--   [ENH] Add function to remove constant columns. @fireddd
--   [ENH] Added `factorize_columns` method which will deprecate the `label_encode` method in future release. @nvamsikrishna05
--   [DOC] Delete Read the Docs project and remove all readthedocs.io references from the repo. Issue #863. @loganthomas
--   [DOC] Updated various documentation sources to reflect pyjanitor-dev ownership. @loganthomas
--   [INF] Fix `isort` automatic checks. Issue #845. @loganthomas
--   [ENH] `complete` function now uses variable args (\*args) - @samukweku
--   [ENH] Set `expand_column`'s `sep` default is `"|"`, same to `pandas.Series.str.get_dummies`. Issue #876. @Zeroto521
--   [ENH] Deprecate `limit` from fill_direction. fill_direction now uses kwargs. @samukweku
--   [ENH] Added `conditional_join` function that supports joins on non-equi operators. @samukweku
--   [INF] Speed up pytest via `-n` (pytest-xdist) option. Issue #881. @Zeroto521
--   [DOC] Add list mark to keep `select_columns`'s example same style. @Zeroto521
--   [ENH] Updated `rename_columns` to take optional function argument for mapping. @nvamsikrishna05
+- [DOC] Fix references and broken links in AUTHORS.rst. @loganthomas
+- [DOC] Updated Broken links in the README and contributing docs. @nvamsikrishna05
+- [INF] Update pre-commit hooks and remove mutable references. Issue #844. @loganthomas
+- [INF] Add GitHub Release pointer to auto-release script. Issue #818. @loganthomas
+- [INF] Updated black version in github actions code-checks to match pre-commit hooks. @nvamsikrishna05
+- [ENH] Add reset_index flag to row_to_names function. @fireddd
+- [ENH] Updated `label_encode` to use pandas factorize instead of scikit-learn LabelEncoder. @nvamsikrishna05
+- [INF] Removed the scikit-learn package from the dependencies from environment-dev.yml and base.in files. @nvamsikrishna05
+- [ENH] Add function to remove constant columns. @fireddd
+- [ENH] Added `factorize_columns` method which will deprecate the `label_encode` method in future release. @nvamsikrishna05
+- [DOC] Delete Read the Docs project and remove all readthedocs.io references from the repo. Issue #863. @loganthomas
+- [DOC] Updated various documentation sources to reflect pyjanitor-dev ownership. @loganthomas
+- [INF] Fix `isort` automatic checks. Issue #845. @loganthomas
+- [ENH] `complete` function now uses variable args (\*args) - @samukweku
+- [ENH] Set `expand_column`'s `sep` default is `"|"`, same to `pandas.Series.str.get_dummies`. Issue #876. @Zeroto521
+- [ENH] Deprecate `limit` from fill_direction. fill_direction now uses kwargs. @samukweku
+- [ENH] Added `conditional_join` function that supports joins on non-equi operators. @samukweku
+- [INF] Speed up pytest via `-n` (pytest-xdist) option. Issue #881. @Zeroto521
+- [DOC] Add list mark to keep `select_columns`'s example same style. @Zeroto521
+- [ENH] Updated `rename_columns` to take optional function argument for mapping. @nvamsikrishna05
 
 ## [v0.21.0] - 2021-07-16
 
--   [ENH] Drop `fill_value` parameter from `complete`. Users can use `fillna` instead. @samukweku
--   [BUG] Fix bug in `pivot_longer` with single level columns. @samukweku
--   [BUG] Disable exchange rates API until we can find another one to hit. @ericmjl
--   [ENH] Change `coalesce` to return columns; also use `bfill`, `ffill`,
-    which is faster than `combine_first` @samukweku
--   [ENH] Use `eval` for string conditions in `update_where`. @samukweku
--   [ENH] Add clearer error messages for `pivot_longer`. h/t to @tdhock
-    for the observation. Issue #836 @samukweku
--   [ENH] `select_columns` now uses variable arguments (\*args),
-    to provide a simpler selection without the need for lists. - @samukweku
--   [ENH] `encode_categoricals` refactored to use generic functions
-    via `functools.dispatch`. - @samukweku
--   [ENH] Updated convert_excel_date to throw meaningful error when values contain non-numeric. @nvamsikrishna05
+- [ENH] Drop `fill_value` parameter from `complete`. Users can use `fillna` instead. @samukweku
+- [BUG] Fix bug in `pivot_longer` with single level columns. @samukweku
+- [BUG] Disable exchange rates API until we can find another one to hit. @ericmjl
+- [ENH] Change `coalesce` to return columns; also use `bfill`, `ffill`,
+which is faster than `combine_first` @samukweku
+- [ENH] Use `eval` for string conditions in `update_where`. @samukweku
+- [ENH] Add clearer error messages for `pivot_longer`. h/t to @tdhock
+for the observation. Issue #836 @samukweku
+- [ENH] `select_columns` now uses variable arguments (\*args),
+to provide a simpler selection without the need for lists. - @samukweku
+- [ENH] `encode_categoricals` refactored to use generic functions
+via `functools.dispatch`. - @samukweku
+- [ENH] Updated convert_excel_date to throw meaningful error when values contain non-numeric. @nvamsikrishna05
 
 ## [v0.20.14] - 2021-03-25
 
--   [ENH] Add `dropna` parameter to groupby_agg. @samukweku
--   [ENH] `complete` adds a `by` parameter to expose explicit missing values per group, via groupby. @samukweku
--   [ENH] Fix check_column to support single inputs - fixes `label_encode`. @zbarry
+- [ENH] Add `dropna` parameter to groupby_agg. @samukweku
+- [ENH] `complete` adds a `by` parameter to expose explicit missing values per group, via groupby. @samukweku
+- [ENH] Fix check_column to support single inputs - fixes `label_encode`. @zbarry
 
 ## [v0.20.13] - 2021-02-25
 
--   [ENH] Performance improvements to `expand_grid`. @samukweku
--   [HOTFIX] Add `multipledispatch` to pip requirements. @ericmjl
+- [ENH] Performance improvements to `expand_grid`. @samukweku
+- [HOTFIX] Add `multipledispatch` to pip requirements. @ericmjl
 
 ## [v0.20.12] - 2021-02-25
 
--   [INF] Auto-release GitHub action maintenance. @loganthomas
+- [INF] Auto-release GitHub action maintenance. @loganthomas
 
 ## [v0.20.11] - 2021-02-24
 
--   [INF] Setup auto-release GitHub action. @loganthomas
--   [INF] Deploy `darglint` package for docstring linting. Issue #745. @loganthomas
--   [ENH] Added optional truncation to `clean_names` function. Issue #753. @richardqiu
--   [ENH] Added `timeseries.flag_jumps()` function. Issue #711. @loganthomas
--   [ENH] `pivot_longer` can handle multiple values in paired columns, and can reshape
-    using a list/tuple of regular expressions in `names_pattern`. @samukweku
--   [ENH] Replaced default numeric conversion of dataframe with a `dtypes` parameter,
-    allowing the user to control the data types. - @samukweku
--   [INF] Loosen dependency specifications. Switch to pip-tools for managing
-    dependencies. Issue #760. @MinchinWeb
--   [DOC] added pipenv installation instructions @evan-anderson
--   [ENH] Add `pivot_wider` function, which is the inverse of the `pivot_longer`
-    function. @samukweku
--   [INF] Add `openpyxl` to `environment-dev.yml`. @samukweku
--   [ENH] Reduce code by reusing existing functions for fill_direction. @samukweku
--   [ENH] Improvements to `pivot_longer` function, with improved speed and cleaner code.
-    `dtypes` parameter dropped; user can change dtypes with pandas' `astype` method, or
-    pyjanitor's `change_type` method. @samukweku
--   [ENH] Add kwargs to `encode_categorical` function, to create ordered categorical columns,
-    or categorical columns with explicit categories. @samukweku
--   [ENH] Improvements to `complete` method. Use `pd.merge` to handle duplicates and
-    null values. @samukweku
--   [ENH] Add `new_column_names` parameter to `process_text`, allowing a user to
-    create a new column name after processing a text column. Also added a `merge_frame`
-    parameter, allowing dataframe merging, if the result of the text processing is a
-    dataframe.@samukweku
--   [ENH] Add `aggfunc` parameter to pivot_wider. @samukweku
--   [ENH] Modified the `check` function in utils to verify if a value is a callable. @samukweku
--   [ENH] Add a base `_select_column` function, using `functools.singledispatch`,
-    to allow for flexible columns selection. @samukweku
--   [ENH] pivot_longer and pivot_wider now support janitor.select_columns syntax,
-    allowing for more flexible and dynamic column selection. @samukweku
+- [INF] Setup auto-release GitHub action. @loganthomas
+- [INF] Deploy `darglint` package for docstring linting. Issue #745. @loganthomas
+- [ENH] Added optional truncation to `clean_names` function. Issue #753. @richardqiu
+- [ENH] Added `timeseries.flag_jumps()` function. Issue #711. @loganthomas
+- [ENH] `pivot_longer` can handle multiple values in paired columns, and can reshape
+using a list/tuple of regular expressions in `names_pattern`. @samukweku
+- [ENH] Replaced default numeric conversion of dataframe with a `dtypes` parameter,
+allowing the user to control the data types. - @samukweku
+- [INF] Loosen dependency specifications. Switch to pip-tools for managing
+dependencies. Issue #760. @MinchinWeb
+- [DOC] added pipenv installation instructions @evan-anderson
+- [ENH] Add `pivot_wider` function, which is the inverse of the `pivot_longer`
+function. @samukweku
+- [INF] Add `openpyxl` to `environment-dev.yml`. @samukweku
+- [ENH] Reduce code by reusing existing functions for fill_direction. @samukweku
+- [ENH] Improvements to `pivot_longer` function, with improved speed and cleaner code.
+`dtypes` parameter dropped; user can change dtypes with pandas' `astype` method, or
+pyjanitor's `change_type` method. @samukweku
+- [ENH] Add kwargs to `encode_categorical` function, to create ordered categorical columns,
+or categorical columns with explicit categories. @samukweku
+- [ENH] Improvements to `complete` method. Use `pd.merge` to handle duplicates and
+null values. @samukweku
+- [ENH] Add `new_column_names` parameter to `process_text`, allowing a user to
+create a new column name after processing a text column. Also added a `merge_frame`
+parameter, allowing dataframe merging, if the result of the text processing is a
+dataframe.@samukweku
+- [ENH] Add `aggfunc` parameter to pivot_wider. @samukweku
+- [ENH] Modified the `check` function in utils to verify if a value is a callable. @samukweku
+- [ENH] Add a base `_select_column` function, using `functools.singledispatch`,
+to allow for flexible columns selection. @samukweku
+- [ENH] pivot_longer and pivot_wider now support janitor.select_columns syntax,
+allowing for more flexible and dynamic column selection. @samukweku
 
 ## [v0.20.10]
 
--   [ENH] Added function `sort_timestamps_monotonically` to timeseries functions @UGuntupalli
--   [ENH] Added the complete function for converting implicit missing values
-    to explicit ones. @samukweku
--   [ENH] Further simplification of expand_grid. @samukweku
--   [BUGFIX] Added copy() method to original dataframe, to avoid mutation. Issue #729. @samukweku
--   [ENH] Added `also` method for running functions in chain with no return values.
--   [DOC] Added a `timeseries` module section to website docs. Issue #742. @loganthomas
--   [ENH] Added a `pivot_longer` function, a wrapper around `pd.melt` and similar to
-    tidyr's `pivot_longer` function. Also added an example notebook. @samukweku
--   [ENH] Fixed code to returns error if `fill_value` is not a dictionary. @samukweku
--   [INF] Welcome bot (.github/config.yml) for new users added. Issue #739. @samukweku
+- [ENH] Added function `sort_timestamps_monotonically` to timeseries functions @UGuntupalli
+- [ENH] Added the complete function for converting implicit missing values
+to explicit ones. @samukweku
+- [ENH] Further simplification of expand_grid. @samukweku
+- [BUGFIX] Added copy() method to original dataframe, to avoid mutation. Issue #729. @samukweku
+- [ENH] Added `also` method for running functions in chain with no return values.
+- [DOC] Added a `timeseries` module section to website docs. Issue #742. @loganthomas
+- [ENH] Added a `pivot_longer` function, a wrapper around `pd.melt` and similar to
+tidyr's `pivot_longer` function. Also added an example notebook. @samukweku
+- [ENH] Fixed code to returns error if `fill_value` is not a dictionary. @samukweku
+- [INF] Welcome bot (.github/config.yml) for new users added. Issue #739. @samukweku
 
 ## [v0.20.9]
 
--   [ENH] Updated groupby_agg function to account for null entries in the `by` argument. @samukweku
--   [ENH] Added function `groupby_topk` to janitor functions @mphirke
+- [ENH] Updated groupby_agg function to account for null entries in the `by` argument. @samukweku
+- [ENH] Added function `groupby_topk` to janitor functions @mphirke
 
 ## [v0.20.8]
 
--   [ENH] Upgraded `update_where` function to use either the pandas query style,
-    or boolean indexing via the `loc` method. Also updated `find_replace` function to use the `loc`
-    method directly, instead of routing it through the `update_where` function. @samukweku
--   [INF] Update `pandas` minimum version to 1.0.0. @hectormz
--   [DOC] Updated the general functions API page to show all available functions. @samukweku
--   [DOC] Fix the few lacking type annotations of functions. @VPerrollaz
--   [DOC] Changed the signature from str to Optional[str] when initialized by None. @VPerrollaz
--   [DOC] Add the Optional type for all signatures of the API. @VPerrollaz
--   [TST] Updated test_expand_grid to account for int dtype difference in Windows OS @samukweku
--   [TST] Make importing `pandas` testing functions follow uniform pattern. @hectormz
--   [ENH] Added `process_text` wrapper function for all Pandas string methods. @samukweku
--   [TST] Only skip tests for non-installed libraries on local machine. @hectormz
--   [DOC] Fix minor issues in documentation. @hectormz
--   [ENH] Added `fill_direction` function for forward/backward fills on missing values
-    for selected columns in a dataframe. @samukweku
--   [ENH] Simpler logic and less lines of code for expand_grid function @samukweku
+- [ENH] Upgraded `update_where` function to use either the pandas query style,
+or boolean indexing via the `loc` method. Also updated `find_replace` function to use the `loc`
+method directly, instead of routing it through the `update_where` function. @samukweku
+- [INF] Update `pandas` minimum version to 1.0.0. @hectormz
+- [DOC] Updated the general functions API page to show all available functions. @samukweku
+- [DOC] Fix the few lacking type annotations of functions. @VPerrollaz
+- [DOC] Changed the signature from str to Optional[str] when initialized by None. @VPerrollaz
+- [DOC] Add the Optional type for all signatures of the API. @VPerrollaz
+- [TST] Updated test_expand_grid to account for int dtype difference in Windows OS @samukweku
+- [TST] Make importing `pandas` testing functions follow uniform pattern. @hectormz
+- [ENH] Added `process_text` wrapper function for all Pandas string methods. @samukweku
+- [TST] Only skip tests for non-installed libraries on local machine. @hectormz
+- [DOC] Fix minor issues in documentation. @hectormz
+- [ENH] Added `fill_direction` function for forward/backward fills on missing values
+for selected columns in a dataframe. @samukweku
+- [ENH] Simpler logic and less lines of code for expand_grid function @samukweku
 
 ## [v0.20.7]
 
--   [TST] Add a test for transform_column to check for nonmutation. @VPerrollaz
--   [ENH] Contributed `expand_grid` function by @samukweku
+- [TST] Add a test for transform_column to check for nonmutation. @VPerrollaz
+- [ENH] Contributed `expand_grid` function by @samukweku
 
 ## [v0.20.6]
 
--   [DOC] Pep8 all examples. @VPerrollaz
--   [TST] Add docstrings to tests @hectormz
--   [INF] Add `debug-statements`, `requirements-txt-fixer`, and `interrogate` to `pre-commit`. @hectormz
--   [ENH] Upgraded transform_column to use df.assign underneath the hood,
-    and also added option to transform column elementwise (via apply)
-    or columnwise (thus operating on a series). @ericmjl
+- [DOC] Pep8 all examples. @VPerrollaz
+- [TST] Add docstrings to tests @hectormz
+- [INF] Add `debug-statements`, `requirements-txt-fixer`, and `interrogate` to `pre-commit`. @hectormz
+- [ENH] Upgraded transform_column to use df.assign underneath the hood,
+and also added option to transform column elementwise (via apply)
+or columnwise (thus operating on a series). @ericmjl
 
 ## [v0.20.5]
 
--   [INF] Replace `pycodestyle` with `flake8` in order to add `pandas-vet` linter @hectormz
--   [ENH] `select_columns()` now raises `NameError` if column label in
-    `search_columns_labels` is missing from `DataFrame` columns. @smu095
+- [INF] Replace `pycodestyle` with `flake8` in order to add `pandas-vet` linter @hectormz
+- [ENH] `select_columns()` now raises `NameError` if column label in
+`search_columns_labels` is missing from `DataFrame` columns. @smu095
 
 ## [v0.20.1]
 
--   [DOC] Added an example for groupby_agg in general functions @samukweku
--   [ENH] Contributed `sort_naturally()` function. @ericmjl
+- [DOC] Added an example for groupby_agg in general functions @samukweku
+- [ENH] Contributed `sort_naturally()` function. @ericmjl
 
 ## [v0.20.0]
 
--   [DOC] Edited transform_column dest_column_name kwarg description to be clearer on defaults by @evan-anderson.
--   [ENH] Replace `apply()` in favor of `pandas` functions in several functions. @hectormz
--   [ENH] Add `ecdf()` Series function by @ericmjl.
--   [DOC] Update API policy for clarity. @ericmjl
--   [ENH] Enforce string conversion when cleaning names. @ericmjl
--   [ENH] Change `find_replace` implementation to use keyword arguments to specify columns to perform find and replace on. @ericmjl
--   [ENH] Add `jitter()` dataframe function by @rahosbach
+- [DOC] Edited transform_column dest_column_name kwarg description to be clearer on defaults by @evan-anderson.
+- [ENH] Replace `apply()` in favor of `pandas` functions in several functions. @hectormz
+- [ENH] Add `ecdf()` Series function by @ericmjl.
+- [DOC] Update API policy for clarity. @ericmjl
+- [ENH] Enforce string conversion when cleaning names. @ericmjl
+- [ENH] Change `find_replace` implementation to use keyword arguments to specify columns to perform find and replace on. @ericmjl
+- [ENH] Add `jitter()` dataframe function by @rahosbach
 
 ## [v0.19.0]
 
--   [ENH] Add xarray support and clone_using / convert_datetime_to_number funcs by @zbarry.
+- [ENH] Add xarray support and clone_using / convert_datetime_to_number funcs by @zbarry.
 
 ## [v0.18.3]
 
--   [ENH] Series toset() functionality #570 @eyaltrabelsi
--   [ENH] Added option to coalesce function to not delete coalesced columns. @gddcunh
--   [ENH] Added functionality to deconcatenate tuple/list/collections in a column to deconcatenate_column @zbarry
--   [ENH] Fix error message when length of new_column_names is wrong @DollofCutty
--   [DOC] Fixed several examples of functional syntax in `functions.py`. @bdice
--   [DOC] Fix #noqa comments showing up in docs by @hectormz
--   [ENH] Add unionizing a group of dataframes' categoricals. @zbarry
--   [DOC] Fix contributions hyperlinks in `AUTHORS.rst` and contributions by @hectormz
--   [INF] Add `pre-commit` hooks to repository by @ericmjl
--   [DOC] Fix formatting code in `CONTRIBUTING.rst` by @hectormz
--   [DOC] Changed the typing for most "column_name(s)" to Hashable rather than enforcing strings, to more closely match Pandas API by @dendrondal
--   [INF] Edited pycodestyle and Black parameters to avoid venvs by @dendrondal
+- [ENH] Series toset() functionality #570 @eyaltrabelsi
+- [ENH] Added option to coalesce function to not delete coalesced columns. @gddcunh
+- [ENH] Added functionality to deconcatenate tuple/list/collections in a column to deconcatenate_column @zbarry
+- [ENH] Fix error message when length of new_column_names is wrong @DollofCutty
+- [DOC] Fixed several examples of functional syntax in `functions.py`. @bdice
+- [DOC] Fix #noqa comments showing up in docs by @hectormz
+- [ENH] Add unionizing a group of dataframes' categoricals. @zbarry
+- [DOC] Fix contributions hyperlinks in `AUTHORS.rst` and contributions by @hectormz
+- [INF] Add `pre-commit` hooks to repository by @ericmjl
+- [DOC] Fix formatting code in `CONTRIBUTING.rst` by @hectormz
+- [DOC] Changed the typing for most "column_name(s)" to Hashable rather than enforcing strings, to more closely match Pandas API by @dendrondal
+- [INF] Edited pycodestyle and Black parameters to avoid venvs by @dendrondal
 
 ## [v0.18.2]
 
--   [INF] Make requirements.txt smaller @eyaltrabelsi
--   [ENH] Add a reset_index parameter to shuffle @eyaltrabelsi
--   [DOC] Added contribution page link to readme @eyaltrabelsi
--   [DOC] fix example for `update_where`, provide a bit more detail, and expand the bad_values example notebook to demonstrate its use by @anzelpwj.
--   [INF] Fix pytest marks by @ericmjl (issue #520)
--   [ENH] add example notebook with use of finance submodule methods by @rahosbach
--   [DOC] added a couple of admonitions for Windows users. h/t @anzelpwj for debugging
-    help when a few tests failed for `win32` @Ram-N
--   [ENH] Pyjanitor for PySpark @zjpoh
--   [ENH] Add pyspark clean_names @zjpoh
--   [ENH] Convert asserts to raise exceptions by @hectormz
--   [ENH] Add decorator functions for missing and error handling @jiafengkevinchen
--   [DOC] Update README with functional `pandas` API example. @ericmjl
--   [INF] Move `get_features_targets()` to new `ml.py` module by @hectormz
--   [ENH] Add chirality to morgan fingerprints in janitor.chemistry submodule by @Clayton-Springer
--   [INF] `import_message` suggests python dist. appropriate installs by @hectormz
--   [ENH] Add count_cumulative_unique() method to janitor.functions submodule by @rahosbach
--   [ENH] Add `update_where()` method to `janitor.spark.functions` submodule by @zjpoh
+- [INF] Make requirements.txt smaller @eyaltrabelsi
+- [ENH] Add a reset_index parameter to shuffle @eyaltrabelsi
+- [DOC] Added contribution page link to readme @eyaltrabelsi
+- [DOC] fix example for `update_where`, provide a bit more detail, and expand the bad_values example notebook to demonstrate its use by @anzelpwj.
+- [INF] Fix pytest marks by @ericmjl (issue #520)
+- [ENH] add example notebook with use of finance submodule methods by @rahosbach
+- [DOC] added a couple of admonitions for Windows users. h/t @anzelpwj for debugging
+help when a few tests failed for `win32` @Ram-N
+- [ENH] Pyjanitor for PySpark @zjpoh
+- [ENH] Add pyspark clean_names @zjpoh
+- [ENH] Convert asserts to raise exceptions by @hectormz
+- [ENH] Add decorator functions for missing and error handling @jiafengkevinchen
+- [DOC] Update README with functional `pandas` API example. @ericmjl
+- [INF] Move `get_features_targets()` to new `ml.py` module by @hectormz
+- [ENH] Add chirality to morgan fingerprints in janitor.chemistry submodule by @Clayton-Springer
+- [INF] `import_message` suggests python dist. appropriate installs by @hectormz
+- [ENH] Add count_cumulative_unique() method to janitor.functions submodule by @rahosbach
+- [ENH] Add `update_where()` method to `janitor.spark.functions` submodule by @zjpoh
 
 ## [v0.18.1]
 
--   [ENH] extend find_replace functionality to allow both exact match and
-    regular-expression-based fuzzy match by @shandou
--   [ENH] add preserve_position kwarg to deconcatenate_column with tests
-    by @shandou and @ericmjl
--   [DOC] add contributions that did not leave `git` traces by @ericmjl
--   [ENH] add inflation adjustment in finance submodule by @rahosbach
--   [DOC] clarified how new functions should be implemented by @shandou
--   [ENH] add optional removal of accents on functions.clean_names, enabled by
-    default by @mralbu
--   [ENH] add camelCase conversion to snake_case on `clean_names` by @ericmjl,
-    h/t @jtaylor for sharing original
--   [ENH] Added `null_flag` function which can mark null values in rows.
-    Implemented by @anzelpwj
--   [ENH] add engineering submodule with unit conversion method by @rahosbach
--   [DOC] add PyPI project description
--   [ENH] add example notebook with use of finance submodule methods
-    by @rahosbach
+- [ENH] extend find_replace functionality to allow both exact match and
+regular-expression-based fuzzy match by @shandou
+- [ENH] add preserve_position kwarg to deconcatenate_column with tests
+by @shandou and @ericmjl
+- [DOC] add contributions that did not leave `git` traces by @ericmjl
+- [ENH] add inflation adjustment in finance submodule by @rahosbach
+- [DOC] clarified how new functions should be implemented by @shandou
+- [ENH] add optional removal of accents on functions.clean_names, enabled by
+default by @mralbu
+- [ENH] add camelCase conversion to snake_case on `clean_names` by @ericmjl,
+h/t @jtaylor for sharing original
+- [ENH] Added `null_flag` function which can mark null values in rows.
+Implemented by @anzelpwj
+- [ENH] add engineering submodule with unit conversion method by @rahosbach
+- [DOC] add PyPI project description
+- [ENH] add example notebook with use of finance submodule methods
+by @rahosbach
 
 For changes that happened prior to v0.18.1,
 please consult the closed PRs,
diff --git a/janitor/functions/clean_names.py b/janitor/functions/clean_names.py
index a38753f..55df959 100644
--- a/janitor/functions/clean_names.py
+++ b/janitor/functions/clean_names.py
@@ -3,7 +3,6 @@
 from __future__ import annotations
 
 import unicodedata
-from typing import Optional, Union
 
 import pandas as pd
 import pandas_flavor as pf
@@ -18,9 +17,9 @@ from janitor.utils import deprecated_alias
 @deprecated_alias(preserve_original_columns="preserve_original_labels")
 def clean_names(
     df: pd.DataFrame,
-    axis: Union[str, None] = "columns",
-    column_names: Union[str, list] = None,
-    strip_underscores: Optional[Union[str, bool]] = None,
+    axis: str = "columns",
+    column_names: str | list = None,
+    strip_underscores: str | bool = None,
     case_type: str = "lower",
     remove_special: bool = False,
     strip_accents: bool = True,
@@ -170,14 +169,14 @@ def clean_names(
 
 
 def _clean_names(
-    obj: Union[pd.Index, pd.Series],
-    strip_underscores: Optional[Union[str, bool]] = None,
-    case_type: str = "lower",
-    remove_special: bool = False,
-    strip_accents: bool = False,
-    enforce_string: bool = False,
-    truncate_limit: int = None,
-) -> Union[pd.Index, pd.Series]:
+    obj: pd.Index | pd.Series,
+    strip_underscores: str | bool,
+    case_type: str,
+    remove_special: bool,
+    strip_accents: bool,
+    enforce_string: bool,
+    truncate_limit: int,
+) -> pd.Index | pd.Series:
     """
     Generic function to clean labels in a pandas object.
     """
@@ -202,9 +201,9 @@ def _clean_names(
 
 
 def _change_case(
-    obj: Union[pd.Index, pd.Series],
+    obj: pd.Index | pd.Series,
     case_type: str,
-) -> Union[pd.Index, pd.Series]:
+) -> pd.Index | pd.Series:
     """Change case of labels in obj."""
     case_types = {"preserve", "upper", "lower", "snake"}
     case_type = case_type.lower()
@@ -226,9 +225,7 @@ def _change_case(
     )
 
 
-def _normalize_1(
-    obj: Union[pd.Index, pd.Series]
-) -> Union[pd.Index, pd.Series]:
+def _normalize_1(obj: pd.Index | pd.Series) -> pd.Index | pd.Series:
     """Perform normalization of labels in obj."""
     FIXES = [(r"[ /:,?()\.-]", "_"), (r"['’]", ""), (r"[\xa0]", "_")]
     for search, replace in FIXES:
@@ -238,8 +235,8 @@ def _normalize_1(
 
 
 def _strip_accents(
-    obj: Union[pd.Index, pd.Series],
-) -> Union[pd.Index, pd.Series]:
+    obj: pd.Index | pd.Series,
+) -> pd.Index | pd.Series:
     """Remove accents from a label.
 
     Inspired from [StackOverflow][so].
@@ -258,9 +255,9 @@ def _strip_accents(
 
 
 def _strip_underscores_func(
-    obj: Union[pd.Index, pd.Series],
-    strip_underscores: Union[str, bool] = None,
-) -> Union[pd.Index, pd.Series]:
+    obj: pd.Index | pd.Series,
+    strip_underscores: str | bool = None,
+) -> pd.Index | pd.Series:
     """Strip underscores."""
     underscore_options = {None, "left", "right", "both", "l", "r", True}
     if strip_underscores not in underscore_options:
diff --git a/janitor/functions/pivot.py b/janitor/functions/pivot.py
index 7efeba4..51bc784 100644
--- a/janitor/functions/pivot.py
+++ b/janitor/functions/pivot.py
@@ -98,7 +98,7 @@ def pivot_longer(
         6     setosa   Petal.Width    0.2
         7  virginica   Petal.Width    1.8
 
-        Split the column labels into parts:
+        Split the column labels into individual columns:
         >>> df.pivot_longer(
         ...     index = 'Species',
         ...     names_to = ('part', 'dimension'),
@@ -167,7 +167,7 @@ def pivot_longer(
         value        int64
         dtype: object
 
-        Use multiple `.value` to reshape dataframe:
+        Use multiple `.value` to reshape the dataframe:
         >>> df = pd.DataFrame(
         ...     [
         ...         {
@@ -265,16 +265,6 @@ def pivot_longer(
         ...             "Gin": [16, 200, 34],
         ...             "Vodka": [20, 33, 18],
         ...         },
-        ...         columns=[
-        ...             "City",
-        ...             "State",
-        ...             "Name",
-        ...             "Mango",
-        ...             "Orange",
-        ...             "Watermelon",
-        ...             "Gin",
-        ...             "Vodka",
-        ...         ],
         ...     )
         >>> df
               City    State      Name  Mango  Orange  Watermelon  Gin  Vodka
diff --git a/janitor/polars/__init__.py b/janitor/polars/__init__.py
index f130df0..f690802 100644
--- a/janitor/polars/__init__.py
+++ b/janitor/polars/__init__.py
@@ -1,8 +1,11 @@
 from __future__ import annotations
 
-from janitor.utils import import_message
+from polars.type_aliases import ColumnNameOrSelector
+
+from janitor.utils import check, import_message
 
 from .clean_names import _clean_column_names, _clean_expr_names
+from .pivot_longer import _pivot_longer, _pivot_longer_dot_value
 
 try:
     import polars as pl
@@ -97,6 +100,236 @@ class PolarsFrame:
             )
         )
 
+    def pivot_longer(
+        self,
+        index: ColumnNameOrSelector = None,
+        column_names: ColumnNameOrSelector = None,
+        names_to: list | tuple | str = "variable",
+        values_to: str = "value",
+        names_sep: str = None,
+        names_pattern: str = None,
+        names_transform: pl.Expr = None,
+    ) -> pl.DataFrame:
+        """
+        Unpivots a DataFrame from *wide* to *long* format.
+
+        It is modeled after the `pivot_longer` function in R's tidyr package,
+        and also takes inspiration from the `melt` function in R's data.table 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.
+
+        For more granular control on the unpivoting, have a look at
+        `pivot_longer_spec`.
+
+        Examples:
+            >>> import polars as pl
+            >>> import polars.selectors as cs
+            >>> import janitor.polars
+            >>> df = pl.DataFrame(
+            ...     {
+            ...         "Sepal.Length": [5.1, 5.9],
+            ...         "Sepal.Width": [3.5, 3.0],
+            ...         "Petal.Length": [1.4, 5.1],
+            ...         "Petal.Width": [0.2, 1.8],
+            ...         "Species": ["setosa", "virginica"],
+            ...     }
+            ... )
+            >>> df
+            shape: (2, 5)
+            ┌──────────────┬─────────────┬──────────────┬─────────────┬───────────┐
+            │ Sepal.Length ┆ Sepal.Width ┆ Petal.Length ┆ Petal.Width ┆ Species   │
+            │ ---          ┆ ---         ┆ ---          ┆ ---         ┆ ---       │
+            │ f64          ┆ f64         ┆ f64          ┆ f64         ┆ str       │
+            ╞══════════════╪═════════════╪══════════════╪═════════════╪═══════════╡
+            │ 5.1          ┆ 3.5         ┆ 1.4          ┆ 0.2         ┆ setosa    │
+            │ 5.9          ┆ 3.0         ┆ 5.1          ┆ 1.8         ┆ virginica │
+            └──────────────┴─────────────┴──────────────┴─────────────┴───────────┘
+
+            Replicate polars' [melt](https://docs.pola.rs/py-polars/html/reference/dataframe/api/polars.DataFrame.melt.html#polars-dataframe-melt):
+            >>> df.janitor.pivot_longer(index = 'Species')
+            shape: (8, 3)
+            ┌───────────┬──────────────┬───────┐
+            │ Species   ┆ variable     ┆ value │
+            │ ---       ┆ ---          ┆ ---   │
+            │ str       ┆ str          ┆ f64   │
+            ╞═══════════╪══════════════╪═══════╡
+            │ setosa    ┆ Sepal.Length ┆ 5.1   │
+            │ virginica ┆ Sepal.Length ┆ 5.9   │
+            │ setosa    ┆ Sepal.Width  ┆ 3.5   │
+            │ virginica ┆ Sepal.Width  ┆ 3.0   │
+            │ setosa    ┆ Petal.Length ┆ 1.4   │
+            │ virginica ┆ Petal.Length ┆ 5.1   │
+            │ setosa    ┆ Petal.Width  ┆ 0.2   │
+            │ virginica ┆ Petal.Width  ┆ 1.8   │
+            └───────────┴──────────────┴───────┘
+
+            Split the column labels into individual columns:
+            >>> df.janitor.pivot_longer(
+            ...     index = 'Species',
+            ...     names_to = ('part', 'dimension'),
+            ...     names_sep = '.',
+            ... ).select('Species','part','dimension','value')
+            shape: (8, 4)
+            ┌───────────┬───────┬───────────┬───────┐
+            │ Species   ┆ part  ┆ dimension ┆ value │
+            │ ---       ┆ ---   ┆ ---       ┆ ---   │
+            │ str       ┆ str   ┆ str       ┆ f64   │
+            ╞═══════════╪═══════╪═══════════╪═══════╡
+            │ setosa    ┆ Sepal ┆ Length    ┆ 5.1   │
+            │ virginica ┆ Sepal ┆ Length    ┆ 5.9   │
+            │ setosa    ┆ Sepal ┆ Width     ┆ 3.5   │
+            │ virginica ┆ Sepal ┆ Width     ┆ 3.0   │
+            │ setosa    ┆ Petal ┆ Length    ┆ 1.4   │
+            │ virginica ┆ Petal ┆ Length    ┆ 5.1   │
+            │ setosa    ┆ Petal ┆ Width     ┆ 0.2   │
+            │ virginica ┆ Petal ┆ Width     ┆ 1.8   │
+            └───────────┴───────┴───────────┴───────┘
+
+            Retain parts of the column names as headers:
+            >>> df.janitor.pivot_longer(
+            ...     index = 'Species',
+            ...     names_to = ('part', '.value'),
+            ...     names_sep = '.',
+            ... ).select('Species','part','Length','Width')
+            shape: (4, 4)
+            ┌───────────┬───────┬────────┬───────┐
+            │ Species   ┆ part  ┆ Length ┆ Width │
+            │ ---       ┆ ---   ┆ ---    ┆ ---   │
+            │ str       ┆ str   ┆ f64    ┆ f64   │
+            ╞═══════════╪═══════╪════════╪═══════╡
+            │ setosa    ┆ Sepal ┆ 5.1    ┆ 3.5   │
+            │ virginica ┆ Sepal ┆ 5.9    ┆ 3.0   │
+            │ setosa    ┆ Petal ┆ 1.4    ┆ 0.2   │
+            │ virginica ┆ Petal ┆ 5.1    ┆ 1.8   │
+            └───────────┴───────┴────────┴───────┘
+
+            Split the column labels based on regex:
+            >>> df = pl.DataFrame({"id": [1], "new_sp_m5564": [2], "newrel_f65": [3]})
+            >>> df
+            shape: (1, 3)
+            ┌─────┬──────────────┬────────────┐
+            │ id  ┆ new_sp_m5564 ┆ newrel_f65 │
+            │ --- ┆ ---          ┆ ---        │
+            │ i64 ┆ i64          ┆ i64        │
+            ╞═════╪══════════════╪════════════╡
+            │ 1   ┆ 2            ┆ 3          │
+            └─────┴──────────────┴────────────┘
+            >>> df.janitor.pivot_longer(
+            ...     index = 'id',
+            ...     names_to = ('diagnosis', 'gender', 'age'),
+            ...     names_pattern = r"new_?(.+)_(.)([0-9]+)",
+            ... ).select('id','diagnosis','gender','age','value').sort(by=pl.all())
+            shape: (2, 5)
+            ┌─────┬───────────┬────────┬──────┬───────┐
+            │ id  ┆ diagnosis ┆ gender ┆ age  ┆ value │
+            │ --- ┆ ---       ┆ ---    ┆ ---  ┆ ---   │
+            │ i64 ┆ str       ┆ str    ┆ str  ┆ i64   │
+            ╞═════╪═══════════╪════════╪══════╪═══════╡
+            │ 1   ┆ rel       ┆ f      ┆ 65   ┆ 3     │
+            │ 1   ┆ sp        ┆ m      ┆ 5564 ┆ 2     │
+            └─────┴───────────┴────────┴──────┴───────┘
+
+            Convert the dtypes of specific columns with `names_transform`:
+            >>> df.janitor.pivot_longer(
+            ...     index = "id",
+            ...     names_pattern=r"new_?(.+)_(.)([0-9]+)",
+            ...     names_to=("diagnosis", "gender", "age"),
+            ...     names_transform=pl.col('age').cast(pl.Int32),
+            ... ).select("id", "diagnosis", "gender", "age", "value").sort(by=pl.all())
+            shape: (2, 5)
+            ┌─────┬───────────┬────────┬──────┬───────┐
+            │ id  ┆ diagnosis ┆ gender ┆ age  ┆ value │
+            │ --- ┆ ---       ┆ ---    ┆ ---  ┆ ---   │
+            │ i64 ┆ str       ┆ str    ┆ i32  ┆ i64   │
+            ╞═════╪═══════════╪════════╪══════╪═══════╡
+            │ 1   ┆ rel       ┆ f      ┆ 65   ┆ 3     │
+            │ 1   ┆ sp        ┆ m      ┆ 5564 ┆ 2     │
+            └─────┴───────────┴────────┴──────┴───────┘
+
+            Use multiple `.value` to reshape the dataframe:
+            >>> df = pl.DataFrame(
+            ...     [
+            ...         {
+            ...             "x_1_mean": 10,
+            ...             "x_2_mean": 20,
+            ...             "y_1_mean": 30,
+            ...             "y_2_mean": 40,
+            ...             "unit": 50,
+            ...         }
+            ...     ]
+            ... )
+            >>> df
+            shape: (1, 5)
+            ┌──────────┬──────────┬──────────┬──────────┬──────┐
+            │ x_1_mean ┆ x_2_mean ┆ y_1_mean ┆ y_2_mean ┆ unit │
+            │ ---      ┆ ---      ┆ ---      ┆ ---      ┆ ---  │
+            │ i64      ┆ i64      ┆ i64      ┆ i64      ┆ i64  │
+            ╞══════════╪══════════╪══════════╪══════════╪══════╡
+            │ 10       ┆ 20       ┆ 30       ┆ 40       ┆ 50   │
+            └──────────┴──────────┴──────────┴──────────┴──────┘
+            >>> df.janitor.pivot_longer(
+            ...     index="unit",
+            ...     names_to=(".value", "time", ".value"),
+            ...     names_pattern=r"(x|y)_([0-9])(_mean)",
+            ... ).select('unit','time','x_mean','y_mean').sort(by=pl.all())
+            shape: (2, 4)
+            ┌──────┬──────┬────────┬────────┐
+            │ unit ┆ time ┆ x_mean ┆ y_mean │
+            │ ---  ┆ ---  ┆ ---    ┆ ---    │
+            │ i64  ┆ str  ┆ i64    ┆ i64    │
+            ╞══════╪══════╪════════╪════════╡
+            │ 50   ┆ 1    ┆ 10     ┆ 30     │
+            │ 50   ┆ 2    ┆ 20     ┆ 40     │
+            └──────┴──────┴────────┴────────┘
+
+        !!! info "New in version 0.28.0"
+
+        Args:
+            index: Column(s) or selector(s) to use as identifier variables.
+            column_names: Column(s) or selector(s) to unpivot.
+            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 overrides `values_to`.
+            values_to: Name of new column as a string that will contain what
+                were previously the values of the columns in `column_names`.
+            names_sep: Determines how the column name is broken up, if
+                `names_to` contains multiple values. It takes the same
+                specification as polars' `str.split` method.
+            names_pattern: Determines how the column name is broken up.
+                It can be a regular expression containing matching groups.
+                It takes the same
+                specification as polars' `str.extract_groups` method.
+            names_transform: Use this option to change the types of columns that
+                have been transformed to rows.
+                This does not applies to the values' columns.
+                Accepts a polars expression or a list of polars expressions.
+                Applicable only if one of names_sep
+                or names_pattern is provided.
+
+        Returns:
+            A polars DataFrame that has been unpivoted from wide to long
+                format.
+        """  # noqa: E501
+        return _pivot_longer(
+            df=self._df,
+            index=index,
+            column_names=column_names,
+            names_pattern=names_pattern,
+            names_sep=names_sep,
+            names_to=names_to,
+            values_to=values_to,
+            names_transform=names_transform,
+        )
+
 
 @pl.api.register_lazyframe_namespace("janitor")
 class PolarsLazyFrame:
@@ -180,6 +413,116 @@ class PolarsLazyFrame:
             )
         )
 
+    def pivot_longer(
+        self,
+        index: ColumnNameOrSelector = None,
+        column_names: ColumnNameOrSelector = None,
+        names_to: list | tuple | str = "variable",
+        values_to: str = "value",
+        names_sep: str = None,
+        names_pattern: str = None,
+        names_transform: pl.Expr = None,
+    ) -> pl.LazyFrame:
+        """
+        Unpivots a LazyFrame from *wide* to *long* format.
+
+        It is modeled after the `pivot_longer` function in R's tidyr package,
+        and also takes inspiration from the `melt` function in R's data.table package.
+
+        This function is useful to massage a LazyFrame 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.
+
+        For more granular control on the unpivoting, have a look at
+        `pivot_longer_spec`.
+
+        Examples:
+            >>> import polars as pl
+            >>> import polars.selectors as cs
+            >>> import janitor.polars
+            >>> df = pl.LazyFrame(
+            ...     {
+            ...         "Sepal.Length": [5.1, 5.9],
+            ...         "Sepal.Width": [3.5, 3.0],
+            ...         "Petal.Length": [1.4, 5.1],
+            ...         "Petal.Width": [0.2, 1.8],
+            ...         "Species": ["setosa", "virginica"],
+            ...     }
+            ... )
+            >>> df.collect()
+            shape: (2, 5)
+            ┌──────────────┬─────────────┬──────────────┬─────────────┬───────────┐
+            │ Sepal.Length ┆ Sepal.Width ┆ Petal.Length ┆ Petal.Width ┆ Species   │
+            │ ---          ┆ ---         ┆ ---          ┆ ---         ┆ ---       │
+            │ f64          ┆ f64         ┆ f64          ┆ f64         ┆ str       │
+            ╞══════════════╪═════════════╪══════════════╪═════════════╪═══════════╡
+            │ 5.1          ┆ 3.5         ┆ 1.4          ┆ 0.2         ┆ setosa    │
+            │ 5.9          ┆ 3.0         ┆ 5.1          ┆ 1.8         ┆ virginica │
+            └──────────────┴─────────────┴──────────────┴─────────────┴───────────┘
+
+            >>> df.janitor.pivot_longer(index = 'Species').collect()
+            shape: (8, 3)
+            ┌───────────┬──────────────┬───────┐
+            │ Species   ┆ variable     ┆ value │
+            │ ---       ┆ ---          ┆ ---   │
+            │ str       ┆ str          ┆ f64   │
+            ╞═══════════╪══════════════╪═══════╡
+            │ setosa    ┆ Sepal.Length ┆ 5.1   │
+            │ virginica ┆ Sepal.Length ┆ 5.9   │
+            │ setosa    ┆ Sepal.Width  ┆ 3.5   │
+            │ virginica ┆ Sepal.Width  ┆ 3.0   │
+            │ setosa    ┆ Petal.Length ┆ 1.4   │
+            │ virginica ┆ Petal.Length ┆ 5.1   │
+            │ setosa    ┆ Petal.Width  ┆ 0.2   │
+            │ virginica ┆ Petal.Width  ┆ 1.8   │
+            └───────────┴──────────────┴───────┘
+
+        !!! info "New in version 0.28.0"
+
+        Args:
+            index: Column(s) or selector(s) to use as identifier variables.
+            column_names: Column(s) or selector(s) to unpivot.
+            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 overrides `values_to`.
+            values_to: Name of new column as a string that will contain what
+                were previously the values of the columns in `column_names`.
+            names_sep: Determines how the column name is broken up, if
+                `names_to` contains multiple values. It takes the same
+                specification as polars' `str.split` method.
+            names_pattern: Determines how the column name is broken up.
+                It can be a regular expression containing matching groups.
+                It takes the same
+                specification as polars' `str.extract_groups` method.
+            names_transform: Use this option to change the types of columns that
+                have been transformed to rows.
+                This does not applies to the values' columns.
+                Accepts a polars expression or a list of polars expressions.
+                Applicable only if one of names_sep
+                or names_pattern is provided.
+
+        Returns:
+            A polars LazyFrame that has been unpivoted from wide to long
+                format.
+        """  # noqa: E501
+        return _pivot_longer(
+            df=self._df,
+            index=index,
+            column_names=column_names,
+            names_pattern=names_pattern,
+            names_sep=names_sep,
+            names_to=names_to,
+            values_to=values_to,
+            names_transform=names_transform,
+        )
+
 
 @pl.api.register_expr_namespace("janitor")
 class PolarsExpr:
@@ -257,3 +600,137 @@ class PolarsExpr:
             enforce_string=enforce_string,
             truncate_limit=truncate_limit,
         )
+
+
+def pivot_longer_spec(
+    df: pl.DataFrame | pl.LazyFrame,
+    spec: pl.DataFrame,
+) -> pl.DataFrame | pl.LazyFrame:
+    """
+    A declarative interface to pivot a DataFrame
+    from wide to long form,
+    where you describe how the data will be unpivoted,
+    using a DataFrame. This gives you, the user,
+    more control over the transformation to long form,
+    using a *spec* DataFrame that describes exactly
+    how data stored in the column names
+    becomes variables.
+
+    It can come in handy for situations where
+    `janitor.polars.pivot_longer`
+    seems inadequate for the transformation.
+
+    !!! info "New in version 0.28.0"
+
+    Examples:
+        >>> import pandas as pd
+        >>> import janitor.polars
+        >>> df = pl.DataFrame(
+        ...     {
+        ...         "Sepal.Length": [5.1, 5.9],
+        ...         "Sepal.Width": [3.5, 3.0],
+        ...         "Petal.Length": [1.4, 5.1],
+        ...         "Petal.Width": [0.2, 1.8],
+        ...         "Species": ["setosa", "virginica"],
+        ...     }
+        ... )
+        >>> df
+        shape: (2, 5)
+        ┌──────────────┬─────────────┬──────────────┬─────────────┬───────────┐
+        │ Sepal.Length ┆ Sepal.Width ┆ Petal.Length ┆ Petal.Width ┆ Species   │
+        │ ---          ┆ ---         ┆ ---          ┆ ---         ┆ ---       │
+        │ f64          ┆ f64         ┆ f64          ┆ f64         ┆ str       │
+        ╞══════════════╪═════════════╪══════════════╪═════════════╪═══════════╡
+        │ 5.1          ┆ 3.5         ┆ 1.4          ┆ 0.2         ┆ setosa    │
+        │ 5.9          ┆ 3.0         ┆ 5.1          ┆ 1.8         ┆ virginica │
+        └──────────────┴─────────────┴──────────────┴─────────────┴───────────┘
+        >>> spec = {'.name':['Sepal.Length','Petal.Length',
+        ...                  'Sepal.Width','Petal.Width'],
+        ...         '.value':['Length','Length','Width','Width'],
+        ...         'part':['Sepal','Petal','Sepal','Petal']}
+        >>> spec = pl.DataFrame(spec)
+        >>> spec
+        shape: (4, 3)
+        ┌──────────────┬────────┬───────┐
+        │ .name        ┆ .value ┆ part  │
+        │ ---          ┆ ---    ┆ ---   │
+        │ str          ┆ str    ┆ str   │
+        ╞══════════════╪════════╪═══════╡
+        │ Sepal.Length ┆ Length ┆ Sepal │
+        │ Petal.Length ┆ Length ┆ Petal │
+        │ Sepal.Width  ┆ Width  ┆ Sepal │
+        │ Petal.Width  ┆ Width  ┆ Petal │
+        └──────────────┴────────┴───────┘
+        >>> df.pipe(pivot_longer_spec,spec=spec)
+        shape: (4, 4)
+        ┌───────────┬────────┬───────┬───────┐
+        │ Species   ┆ Length ┆ Width ┆ part  │
+        │ ---       ┆ ---    ┆ ---   ┆ ---   │
+        │ str       ┆ f64    ┆ f64   ┆ str   │
+        ╞═══════════╪════════╪═══════╪═══════╡
+        │ setosa    ┆ 5.1    ┆ 3.5   ┆ Sepal │
+        │ virginica ┆ 5.9    ┆ 3.0   ┆ Sepal │
+        │ setosa    ┆ 1.4    ┆ 0.2   ┆ Petal │
+        │ virginica ┆ 5.1    ┆ 1.8   ┆ Petal │
+        └───────────┴────────┴───────┴───────┘
+
+    Args:
+        df: The source DataFrame to unpivot.
+        spec: A specification DataFrame.
+            At a minimum, the spec DataFrame
+            must have a `.name` column
+            and a `.value` column.
+            The `.name` column  should contain the
+            columns in the source DataFrame that will be
+            transformed to long form.
+            The `.value` column gives the name of the column
+            that the values in the source DataFrame will go into.
+            Additional columns in the spec DataFrame
+            should be named to match columns
+            in the long format of the dataset and contain values
+            corresponding to columns pivoted from the wide format.
+            Note that these additional columns should not already exist
+            in the source DataFrame.
+
+    Raises:
+        KeyError: If `.name` or `.value` is missing from the spec's columns.
+        ValueError: If the labels in `spec['.name']` is not unique.
+
+    Returns:
+        A polars DataFrame/LazyFrame.
+    """
+    check("spec", spec, [pl.DataFrame])
+    if ".name" not in spec.columns:
+        raise KeyError(
+            "Kindly ensure the spec DataFrame has a `.name` column."
+        )
+    if ".value" not in spec.columns:
+        raise KeyError(
+            "Kindly ensure the spec DataFrame has a `.value` column."
+        )
+    if spec.select(pl.col(".name").is_duplicated().any()).item():
+        raise ValueError("The labels in the `.name` column should be unique.")
+
+    exclude = set(df.columns).intersection(spec.columns)
+    if exclude:
+        raise ValueError(
+            f"Labels {*exclude, } in the spec dataframe already exist "
+            "as column labels in the source dataframe. "
+            "Kindly ensure the spec DataFrame's columns "
+            "are not present in the source DataFrame."
+        )
+
+    if spec.columns[:2] != [".name", ".value"]:
+        raise ValueError(
+            "The first two columns of the spec DataFrame "
+            "should be '.name' and '.value', "
+            "with '.name' coming before '.value'."
+        )
+
+    return _pivot_longer_dot_value(
+        df=df,
+        spec=spec,
+    )
+
+
+__all__ = ["PolarsFrame", "PolarsLazyFrame", "pivot_longer_spec"]
diff --git a/janitor/polars/pivot_longer.py b/janitor/polars/pivot_longer.py
new file mode 100644
index 0000000..15a702e
--- /dev/null
+++ b/janitor/polars/pivot_longer.py
@@ -0,0 +1,338 @@
+"""pivot_longer implementation for polars."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Any, Iterable
+
+from janitor.utils import check, import_message
+
+try:
+    import polars as pl
+    import polars.selectors as cs
+    from polars.type_aliases import ColumnNameOrSelector
+except ImportError:
+    import_message(
+        submodule="polars",
+        package="polars",
+        conda_channel="conda-forge",
+        pip_install=True,
+    )
+
+
+def _pivot_longer(
+    df: pl.DataFrame | pl.LazyFrame,
+    index: ColumnNameOrSelector,
+    column_names: ColumnNameOrSelector,
+    names_to: list | tuple | str,
+    values_to: str,
+    names_sep: str,
+    names_pattern: str,
+    names_transform: pl.Expr,
+) -> pl.DataFrame | pl.LazyFrame:
+    """
+    Unpivots a DataFrame/LazyFrame from wide to long form.
+    """
+
+    (
+        df,
+        index,
+        column_names,
+        names_to,
+        values_to,
+        names_sep,
+        names_pattern,
+        names_transform,
+    ) = _data_checks_pivot_longer(
+        df=df,
+        index=index,
+        column_names=column_names,
+        names_to=names_to,
+        values_to=values_to,
+        names_sep=names_sep,
+        names_pattern=names_pattern,
+        names_transform=names_transform,
+    )
+
+    if not column_names:
+        return df
+
+    if all((names_pattern is None, names_sep is None)):
+        return df.melt(
+            id_vars=index,
+            value_vars=column_names,
+            variable_name=names_to,
+            value_name=values_to,
+        )
+
+    df = df.select(pl.col(index), pl.col(column_names))
+    if isinstance(names_to, str):
+        names_to = [names_to]
+
+    spec = _pivot_longer_create_spec(
+        column_names=column_names,
+        names_to=names_to,
+        names_sep=names_sep,
+        names_pattern=names_pattern,
+        values_to=values_to,
+        names_transform=names_transform,
+    )
+
+    return _pivot_longer_dot_value(df=df, spec=spec)
+
+
+def _pivot_longer_create_spec(
+    column_names: Iterable,
+    names_to: Iterable,
+    names_sep: str | None,
+    names_pattern: str | None,
+    values_to: str,
+    names_transform: pl.Expr,
+) -> pl.DataFrame:
+    """
+    This is where the spec DataFrame is created,
+    before the transformation to long form.
+    """
+    spec = pl.DataFrame({".name": column_names})
+    if names_sep is not None:
+        expression = (
+            pl.col(".name")
+            .str.split(by=names_sep)
+            .list.to_struct(n_field_strategy="max_width")
+            .alias("extract")
+        )
+    else:
+        expression = (
+            pl.col(".name")
+            .str.extract_groups(pattern=names_pattern)
+            .alias("extract")
+        )
+    spec = spec.with_columns(expression)
+    len_fields = len(spec.get_column("extract").struct.fields)
+    len_names_to = len(names_to)
+
+    if len_names_to != len_fields:
+        raise ValueError(
+            f"The length of names_to does not match "
+            "the number of fields extracted. "
+            f"The length of names_to is {len_names_to} "
+            "while the number of fields extracted is "
+            f"{len_fields}."
+        )
+    if names_pattern is not None:
+        expression = pl.exclude(".name").is_null().any()
+        expression = pl.any_horizontal(expression)
+        null_check = (
+            spec.unnest(columns="extract")
+            .filter(expression)
+            .get_column(".name")
+        )
+        if null_check.len():
+            column_name = null_check.gather(0).item()
+            raise ValueError(
+                f"Column label '{column_name}' "
+                "could not be matched with any of the groups "
+                "in the provided regex. Kindly provide a regular expression "
+                "(with the correct groups) that matches all labels in the columns."
+            )
+    if names_to.count(".value") < 2:
+        expression = pl.col("extract").struct.rename_fields(names=names_to)
+        spec = spec.with_columns(expression).unnest(columns="extract")
+    else:
+        spec = _squash_multiple_dot_value(spec=spec, names_to=names_to)
+    if ".value" not in names_to:
+        expression = pl.lit(value=values_to).alias(".value")
+        spec = spec.with_columns(expression)
+
+    spec = spec.select(
+        pl.col([".name", ".value"]), pl.exclude([".name", ".value"])
+    )
+    if names_transform is not None:
+        spec = spec.with_columns(names_transform)
+    return spec
+
+
+def _pivot_longer_dot_value(
+    df: pl.DataFrame | pl.LazyFrame, spec: pl.DataFrame
+) -> pl.DataFrame | pl.LazyFrame:
+    """
+    Reshape DataFrame to long form based on metadata in `spec`.
+    """
+    index = [column for column in df.columns if column not in spec[".name"]]
+    not_dot_value = [
+        column for column in spec.columns if column not in {".name", ".value"}
+    ]
+    idx = "".join(spec.columns)
+    if not_dot_value:
+        # assign a number to each group (grouped by not_dot_value)
+        expression = pl.first(idx).over(not_dot_value).rank("dense").sub(1)
+        spec = spec.with_row_index(name=idx).with_columns(expression)
+    else:
+        # use a cumulative count to properly pair the columns
+        # grouped by .value
+        expression = pl.cum_count(".value").over(".value").alias(idx)
+        spec = spec.with_columns(expression)
+    mapping = defaultdict(list)
+    for position, column_name, replacement_name in zip(
+        spec.get_column(name=idx),
+        spec.get_column(name=".name"),
+        spec.get_column(name=".value"),
+    ):
+        expression = pl.col(column_name).alias(replacement_name)
+        mapping[position].append(expression)
+
+    mapping = (
+        (
+            [
+                *index,
+                *columns_to_select,
+            ],
+            pl.lit(position, dtype=pl.UInt32).alias(idx),
+        )
+        for position, columns_to_select in mapping.items()
+    )
+    df = [
+        df.select(columns_to_select).with_columns(position)
+        for columns_to_select, position in mapping
+    ]
+    # rechunking can be expensive;
+    # however subsequent operations are faster
+    # since data is contiguous in memory
+    df = pl.concat(df, how="diagonal_relaxed", rechunk=True)
+    expression = pl.cum_count(".value").over(".value").eq(1)
+    dot_value = spec.filter(expression).select(".value")
+    columns_to_select = [*index, *dot_value.to_series(0)]
+    if not_dot_value:
+        if isinstance(df, pl.LazyFrame):
+            ranges = df.select(idx).collect().get_column(idx)
+        else:
+            ranges = df.get_column(idx)
+        spec = spec.select(pl.struct(not_dot_value))
+        _value = spec.columns[0]
+        expression = pl.cum_count(_value).over(_value).eq(1)
+        # using a gather approach, instead of a join
+        # offers more performance - not sure why
+        # maybe in the join there is another rechunking?
+        spec = spec.filter(expression).select(pl.col(_value).gather(ranges))
+        df = df.with_columns(spec).unnest(_value)
+        columns_to_select.extend(not_dot_value)
+    return df.select(columns_to_select)
+
+
+def _squash_multiple_dot_value(
+    spec: pl.DataFrame, names_to: Iterable
+) -> pl.DataFrame:
+    """
+    Combine multiple .values into a single .value column
+    """
+    extract = spec.get_column("extract")
+    fields = extract.struct.fields
+    dot_value = [
+        field for field, label in zip(fields, names_to) if label == ".value"
+    ]
+    dot_value = pl.concat_str(dot_value).alias(".value")
+    not_dot_value = [
+        pl.col(field).alias(label)
+        for field, label in zip(fields, names_to)
+        if label != ".value"
+    ]
+    select_expr = [".name", dot_value]
+    if not_dot_value:
+        select_expr.extend(not_dot_value)
+
+    return spec.unnest("extract").select(select_expr)
+
+
+def _data_checks_pivot_longer(
+    df,
+    index,
+    column_names,
+    names_to,
+    values_to,
+    names_sep,
+    names_pattern,
+    names_transform,
+) -> tuple:
+    """
+    This function majorly does type checks on the passed arguments.
+
+    This function is executed before proceeding to the computation phase.
+
+    Type annotations are not provided because this function is where type
+    checking happens.
+    """
+
+    def _check_type(arg_name: str, arg_value: Any):
+        """
+        Raise if argument is not a valid type
+        """
+
+        def _check_type_single(entry):
+            if (
+                not isinstance(entry, str)
+                and not cs.is_selector(entry)
+                and not isinstance(entry, pl.Expr)
+            ):
+                raise TypeError(
+                    f"The argument passed to the {arg_name} parameter "
+                    "should be a type that is supported in the polars' "
+                    "select function."
+                )
+
+        if isinstance(arg_value, (list, tuple)):
+            for entry in arg_value:
+                _check_type_single(entry=entry)
+        else:
+            _check_type_single(entry=arg_value)
+
+    if (index is None) and (column_names is None):
+        column_names = df.columns
+        index = []
+    elif (index is not None) and (column_names is not None):
+        _check_type(arg_name="index", arg_value=index)
+        index = df.select(index).columns
+        _check_type(arg_name="column_names", arg_value=column_names)
+        column_names = df.select(column_names).columns
+
+    elif (index is None) and (column_names is not None):
+        _check_type(arg_name="column_names", arg_value=column_names)
+        column_names = df.select(column_names).columns
+        index = df.select(pl.exclude(column_names)).columns
+
+    elif (index is not None) and (column_names is None):
+        _check_type(arg_name="index", arg_value=index)
+        index = df.select(index).columns
+        column_names = df.select(pl.exclude(index)).columns
+
+    check("names_to", names_to, [list, tuple, str])
+    if isinstance(names_to, (list, tuple)):
+        uniques = set()
+        for word in names_to:
+            check(f"'{word}' in names_to", word, [str])
+            if (word in uniques) and (word != ".value"):
+                raise ValueError(f"'{word}' is duplicated in names_to.")
+            uniques.add(word)
+
+    if names_sep and names_pattern:
+        raise ValueError(
+            "Only one of names_pattern or names_sep should be provided."
+        )
+
+    if names_sep is not None:
+        check("names_sep", names_sep, [str])
+
+    if names_pattern is not None:
+        check("names_pattern", names_pattern, [str])
+
+    check("values_to", values_to, [str])
+
+    return (
+        df,
+        index,
+        column_names,
+        names_to,
+        values_to,
+        names_sep,
+        names_pattern,
+        names_transform,
+    )
diff --git a/mkdocs/api/polars.md b/mkdocs/api/polars.md
index f5d5fed..27809fc 100644
--- a/mkdocs/api/polars.md
+++ b/mkdocs/api/polars.md
@@ -3,6 +3,6 @@
 ::: janitor.polars
     options:
       members:
-      - PolarsExpr
       - PolarsFrame
       - PolarsLazyFrame
+      - pivot_longer_spec


Test patch:
diff --git a/tests/polars/functions/test_pivot_longer_polars.py b/tests/polars/functions/test_pivot_longer_polars.py
new file mode 100644
index 0000000..46bc61c
--- /dev/null
+++ b/tests/polars/functions/test_pivot_longer_polars.py
@@ -0,0 +1,601 @@
+import polars as pl
+import polars.selectors as cs
+import pytest
+from polars.testing import assert_frame_equal
+
+import janitor.polars  # noqa: F401
+
+
+@pytest.fixture
+def df_checks():
+    """fixture dataframe"""
+    return pl.DataFrame(
+        {
+            "famid": [1, 1, 1, 2, 2, 2, 3, 3, 3],
+            "birth": [1, 2, 3, 1, 2, 3, 1, 2, 3],
+            "ht1": [2.8, 2.9, 2.2, 2, 1.8, 1.9, 2.2, 2.3, 2.1],
+            "ht2": [3.4, 3.8, 2.9, 3.2, 2.8, 2.4, 3.3, 3.4, 2.9],
+        }
+    )
+
+
+def test_type_index(df_checks):
+    """Raise TypeError if wrong type is provided for the index."""
+    msg = "The argument passed to the index parameter "
+    msg += "should be a type that is supported in the.+"
+    with pytest.raises(TypeError, match=msg):
+        df_checks.janitor.pivot_longer(index=2007, names_sep="_")
+
+
+def test_type_column_names(df_checks):
+    """Raise TypeError if wrong type is provided for column_names."""
+    msg = "The argument passed to the column_names parameter "
+    msg += "should be a type that is supported in the.+"
+    with pytest.raises(TypeError, match=msg):
+        df_checks.janitor.pivot_longer(column_names=2007, names_sep="_")
+
+
+def test_type_names_to(df_checks):
+    """Raise TypeError if wrong type is provided for names_to."""
+    msg = "names_to should be one of .+"
+    with pytest.raises(TypeError, match=msg):
+        df_checks.janitor.pivot_longer(names_to=2007, names_sep="_")
+
+
+def test_subtype_names_to(df_checks):
+    """
+    Raise TypeError if names_to is a sequence
+    and the wrong type is provided for entries
+    in names_to.
+    """
+    with pytest.raises(TypeError, match="'1' in names_to.+"):
+        df_checks.janitor.pivot_longer(names_to=[1], names_sep="_")
+
+
+def test_duplicate_names_to(df_checks):
+    """Raise error if names_to contains duplicates."""
+    with pytest.raises(ValueError, match="'y' is duplicated in names_to."):
+        df_checks.janitor.pivot_longer(
+            names_to=["y", "y"], names_pattern="(.+)(.)"
+        )
+
+
+def test_both_names_sep_and_pattern(df_checks):
+    """
+    Raise ValueError if both names_sep
+    and names_pattern is provided.
+    """
+    with pytest.raises(
+        ValueError,
+        match="Only one of names_pattern or names_sep should be provided.",
+    ):
+        df_checks.janitor.pivot_longer(
+            names_to=["rar", "bar"], names_sep="-", names_pattern="(.+)(.)"
+        )
+
+
+def test_name_pattern_wrong_type(df_checks):
+    """Raise TypeError if the wrong type is provided for names_pattern."""
+    with pytest.raises(TypeError, match="names_pattern should be one of.+"):
+        df_checks.janitor.pivot_longer(
+            names_to=["rar", "bar"], names_pattern=2007
+        )
+
+
+def test_name_sep_wrong_type(df_checks):
+    """Raise TypeError if the wrong type is provided for names_sep."""
+    with pytest.raises(TypeError, match="names_sep should be one of.+"):
+        df_checks.janitor.pivot_longer(
+            names_to=[".value", "num"], names_sep=["_"]
+        )
+
+
+def test_values_to_wrong_type(df_checks):
+    """Raise TypeError if the wrong type is provided for `values_to`."""
+    with pytest.raises(TypeError, match="values_to should be one of.+"):
+        df_checks.janitor.pivot_longer(values_to={"salvo"}, names_sep="_")
+
+
+def test_pivot_index_only(df_checks):
+    """Test output if only index is passed."""
+    result = df_checks.janitor.pivot_longer(
+        index=["famid", "birth"],
+        names_to="dim",
+        values_to="num",
+    )
+
+    actual = df_checks.melt(
+        id_vars=["famid", "birth"], variable_name="dim", value_name="num"
+    )
+
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+def test_pivot_column_only(df_checks):
+    """Test output if only column_names is passed."""
+    result = df_checks.janitor.pivot_longer(
+        column_names=["ht1", "ht2"],
+        names_to="dim",
+        values_to="num",
+    )
+
+    actual = df_checks.melt(
+        id_vars=["famid", "birth"],
+        variable_name="dim",
+        value_name="num",
+    )
+
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+def test_names_to_names_pattern_len(df_checks):
+    """ "
+    Raise ValueError
+    if length of names_to
+    does not match the number of fields extracted
+    """
+    msg = "The length of names_to does "
+    msg += "not match the number of fields extracted.+"
+    with pytest.raises(ValueError, match=msg):
+
+        df_checks.janitor.pivot_longer(
+            column_names=cs.starts_with("ht"),
+            names_to=(".value"),
+            names_pattern=r"(\d+)(.)",
+        )
+
+
+def test_names_to_names_pattern_mismatch(df_checks):
+    """ "
+    Raise ValueError if
+    there is no match for the provided names_pattern
+    """
+    msg = "Column label 'ht1' could not be matched with "
+    msg += "any of the groups in the provided regex.+"
+
+    with pytest.raises(ValueError, match=msg):
+
+        df_checks.janitor.pivot_longer(
+            column_names=cs.starts_with("ht"),
+            names_to=(".value", "age"),
+            names_pattern=r"(\d+)(.)",
+        )
+
+
+def test_names_pat_str(df_checks):
+    """
+    Test output when names_pattern is a string,
+    and .value is present.
+    """
+    result = df_checks.janitor.pivot_longer(
+        column_names=cs.starts_with("ht"),
+        names_to=(".value", "age"),
+        names_pattern="(.+)(.)",
+        names_transform=pl.col("age").cast(pl.Int64),
+    ).sort(by=pl.all())
+
+    actual = [
+        {"famid": 1, "birth": 1, "age": 1, "ht": 2.8},
+        {"famid": 1, "birth": 1, "age": 2, "ht": 3.4},
+        {"famid": 1, "birth": 2, "age": 1, "ht": 2.9},
+        {"famid": 1, "birth": 2, "age": 2, "ht": 3.8},
+        {"famid": 1, "birth": 3, "age": 1, "ht": 2.2},
+        {"famid": 1, "birth": 3, "age": 2, "ht": 2.9},
+        {"famid": 2, "birth": 1, "age": 1, "ht": 2.0},
+        {"famid": 2, "birth": 1, "age": 2, "ht": 3.2},
+        {"famid": 2, "birth": 2, "age": 1, "ht": 1.8},
+        {"famid": 2, "birth": 2, "age": 2, "ht": 2.8},
+        {"famid": 2, "birth": 3, "age": 1, "ht": 1.9},
+        {"famid": 2, "birth": 3, "age": 2, "ht": 2.4},
+        {"famid": 3, "birth": 1, "age": 1, "ht": 2.2},
+        {"famid": 3, "birth": 1, "age": 2, "ht": 3.3},
+        {"famid": 3, "birth": 2, "age": 1, "ht": 2.3},
+        {"famid": 3, "birth": 2, "age": 2, "ht": 3.4},
+        {"famid": 3, "birth": 3, "age": 1, "ht": 2.1},
+        {"famid": 3, "birth": 3, "age": 2, "ht": 2.9},
+    ]
+    actual = pl.DataFrame(actual).sort(by=pl.all())
+
+    assert_frame_equal(
+        result, actual, check_dtype=False, check_column_order=False
+    )
+
+
+def test_no_column_names(df_checks):
+    """
+    Test output if all the columns
+    are assigned to the index parameter.
+    """
+    assert_frame_equal(
+        df_checks.janitor.pivot_longer(index=pl.all()),
+        df_checks,
+    )
+
+
+@pytest.fixture
+def test_df():
+    """Fixture DataFrame"""
+    return pl.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,
+            ],
+        }
+    )
+
+
+actual = [
+    {
+        "set": "off",
+        "loc": "A",
+        "lat": 121.271083,
+        "long": -7.188632000000001,
+    },
+    {"set": "off", "loc": "B", "lat": 75.93845266, "long": -143.2288569},
+    {"set": "off", "loc": "C", "lat": 135.043791, "long": 21.242563},
+    {
+        "set": "off",
+        "loc": "D",
+        "lat": 134.51128400000002,
+        "long": 40.937416999999996,
+    },
+    {"set": "off", "loc": "E", "lat": 134.484374, "long": 40.78472},
+    {
+        "set": "off",
+        "loc": "F",
+        "lat": 137.962195,
+        "long": 22.905889000000002,
+    },
+    {
+        "set": "pt",
+        "loc": "G",
+        "lat": 100.07548220000001,
+        "long": 4.472089953,
+    },
+    {"set": "pt", "loc": "H", "lat": 75.191326, "long": -144.387785},
+    {
+        "set": "pt",
+        "loc": "I",
+        "lat": 122.65134479999999,
+        "long": -40.45611048,
+    },
+    {
+        "set": "pt",
+        "loc": "J",
+        "lat": 124.13553329999999,
+        "long": -46.07156181,
+    },
+    {
+        "set": "pt",
+        "loc": "K",
+        "lat": 124.13553329999999,
+        "long": -46.07156181,
+    },
+    {
+        "set": "pt",
+        "loc": "L",
+        "lat": 124.01028909999998,
+        "long": -46.01594293,
+    },
+]
+
+actual = pl.DataFrame(actual).sort(by=pl.all())
+
+
+def test_names_pattern_dot_value(test_df):
+    """Test output for names_pattern and .value."""
+
+    result = test_df.janitor.pivot_longer(
+        column_names=pl.all(),
+        names_to=["set", ".value"],
+        names_pattern="(.+)_(.+)",
+    ).sort(by=["loc", "lat", "long"])
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+def test_names_sep_dot_value(test_df):
+    """Test output for names_pattern and .value."""
+
+    result = test_df.janitor.pivot_longer(
+        column_names=pl.all(),
+        names_to=["set", ".value"],
+        names_sep="_",
+    ).sort(by=["loc", "lat", "long"])
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+@pytest.fixture
+def not_dot_value():
+    """Fixture DataFrame"""
+    return pl.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],
+        }
+    )
+
+
+def test_not_dot_value_sep(not_dot_value):
+    """Test output when names_sep and no dot_value"""
+
+    result = (
+        not_dot_value.janitor.pivot_longer(
+            "country",
+            names_to=("event", "year"),
+            names_sep="_",
+            values_to="score",
+        )
+        .select("country", "event", "year", "score")
+        .sort(by=pl.all())
+    )
+
+    actual = (
+        not_dot_value.melt(id_vars="country", value_name="score")
+        .select(
+            "country",
+            "score",
+            pl.col("variable")
+            .str.split("_")
+            .list.to_struct(fields=["event", "year"]),
+        )
+        .unnest("variable")
+        .select("country", "event", "year", "score")
+        .sort(by=pl.all())
+    )
+
+    assert_frame_equal(result, actual)
+
+
+def test_not_dot_value_sep2(not_dot_value):
+    """Test output when names_sep and no dot_value"""
+
+    result = not_dot_value.janitor.pivot_longer(
+        "country",
+        names_to="event",
+        names_sep="/",
+        values_to="score",
+    )
+
+    actual = not_dot_value.melt(
+        "country", variable_name="event", value_name="score"
+    )
+
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+def test_not_dot_value_pattern(not_dot_value):
+    """Test output when names_pattern is a string and no dot_value"""
+
+    result = (
+        not_dot_value.janitor.pivot_longer(
+            index="country",
+            names_to=("event", "year"),
+            names_pattern=r"(.+)_(.+)",
+            values_to="score",
+        )
+        .select("country", "event", "year", "score")
+        .sort(by=pl.all())
+    )
+
+    actual = (
+        not_dot_value.melt(id_vars="country", value_name="score")
+        .select(
+            "country",
+            "score",
+            pl.col("variable")
+            .str.split("_")
+            .list.to_struct(fields=["event", "year"]),
+        )
+        .unnest("variable")
+        .select("country", "event", "year", "score")
+        .sort(by=pl.all())
+    )
+
+    assert_frame_equal(result, actual)
+
+
+def test_multiple_dot_value():
+    """Test output for multiple .value."""
+    df = pl.DataFrame(
+        {
+            "x_1_mean": [1, 2, 3, 4],
+            "x_2_mean": [1, 1, 0, 0],
+            "x_1_sd": [0, 1, 1, 1],
+            "x_2_sd": [0.739, 0.219, 1.46, 0.918],
+            "y_1_mean": [1, 2, 3, 4],
+            "y_2_mean": [1, 1, 0, 0],
+            "y_1_sd": [0, 1, 1, 1],
+            "y_2_sd": [-0.525, 0.623, -0.705, 0.662],
+            "unit": [1, 2, 3, 4],
+        }
+    )
+
+    result = (
+        df.janitor.pivot_longer(
+            index="unit",
+            names_to=(".value", "time", ".value"),
+            names_pattern=r"(x|y)_([0-9])(_mean|_sd)",
+            names_transform=pl.col("time").cast(pl.Int64),
+        )
+        .select("unit", "time", "x_mean", "x_sd", "y_mean", "y_sd")
+        .sort(by=pl.all())
+    )
+
+    actual = {
+        "unit": [1, 2, 3, 4, 1, 2, 3, 4],
+        "time": [1, 1, 1, 1, 2, 2, 2, 2],
+        "x_mean": [1, 2, 3, 4, 1, 1, 0, 0],
+        "x_sd": [0.0, 1.0, 1.0, 1.0, 0.739, 0.219, 1.46, 0.918],
+        "y_mean": [1, 2, 3, 4, 1, 1, 0, 0],
+        "y_sd": [0.0, 1.0, 1.0, 1.0, -0.525, 0.623, -0.705, 0.662],
+    }
+
+    actual = pl.DataFrame(actual).sort(by=pl.all())
+
+    assert_frame_equal(result, actual, check_column_order=False)
+
+
+@pytest.fixture
+def single_val():
+    """fixture dataframe"""
+    return pl.DataFrame(
+        {
+            "id": [1, 2, 3],
+            "x1": [4, 5, 6],
+            "x2": [5, 6, 7],
+        }
+    )
+
+
+def test_multiple_dot_value2(single_val):
+    """Test output for multiple .value."""
+
+    result = single_val.janitor.pivot_longer(
+        index="id", names_to=(".value", ".value"), names_pattern="(.)(.)"
+    )
+
+    assert_frame_equal(result, single_val, check_column_order=False)
+
+
+actual3 = [
+    {"id": 1, "x": 4},
+    {"id": 2, "x": 5},
+    {"id": 3, "x": 6},
+    {"id": 1, "x": 5},
+    {"id": 2, "x": 6},
+    {"id": 3, "x": 7},
+]
+
+actual3 = pl.DataFrame(actual3)
+
+
+def test_names_pattern_single_column(single_val):
+    """
+    Test output if names_to is only '.value'.
+    """
+
+    result = single_val.janitor.pivot_longer(
+        "id", names_to=".value", names_pattern="(.)."
+    )
+
+    assert_frame_equal(result, actual3, check_column_order=False)
+
+
+def test_names_pattern_single_column_not_dot_value(single_val):
+    """
+    Test output if names_to is not '.value'.
+    """
+    result = single_val.janitor.pivot_longer(
+        index="id", column_names="x1", names_to="yA", names_pattern="(.+)"
+    )
+
+    assert_frame_equal(
+        result,
+        single_val.melt(id_vars="id", value_vars="x1", variable_name="yA"),
+        check_column_order=False,
+    )
+
+
+def test_names_pattern_single_column_not_dot_value1(single_val):
+    """
+    Test output if names_to is not '.value'.
+    """
+    result = single_val.select("x1").janitor.pivot_longer(
+        names_to="yA", names_pattern="(.+)"
+    )
+
+    assert_frame_equal(
+        result,
+        single_val.select("x1").melt(variable_name="yA"),
+        check_column_order=False,
+    )
+
+
+@pytest.fixture
+def df_null():
+    "Dataframe with nulls."
+    return pl.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",
+                None,
+                "2004-04-05",
+                "2009-08-27",
+                "2005-02-28",
+            ],
+            "gender_child1": [1, 2, 2, 1, 2],
+            "gender_child2": [2.0, None, 2.0, 1.0, 1.0],
+        }
+    )
+
+
+def test_names_pattern_nulls_in_data(df_null):
+    """Test output if nulls are present in data."""
+    result = (
+        df_null.janitor.pivot_longer(
+            index="family",
+            names_to=[".value", "child"],
+            names_pattern=r"(.+)_(.+)",
+        )
+        .select("family", "child", "dob", "gender")
+        .sort(by=pl.all())
+    )
+
+    actual = [
+        {"family": 1, "child": "child1", "dob": "1998-11-26", "gender": 1.0},
+        {"family": 2, "child": "child1", "dob": "1996-06-22", "gender": 2.0},
+        {"family": 3, "child": "child1", "dob": "2002-07-11", "gender": 2.0},
+        {"family": 4, "child": "child1", "dob": "2004-10-10", "gender": 1.0},
+        {"family": 5, "child": "child1", "dob": "2000-12-05", "gender": 2.0},
+        {"family": 1, "child": "child2", "dob": "2000-01-29", "gender": 2.0},
+        {"family": 2, "child": "child2", "dob": None, "gender": None},
+        {"family": 3, "child": "child2", "dob": "2004-04-05", "gender": 2.0},
+        {"family": 4, "child": "child2", "dob": "2009-08-27", "gender": 1.0},
+        {"family": 5, "child": "child2", "dob": "2005-02-28", "gender": 1.0},
+    ]
+
+    actual = pl.DataFrame(actual).sort(by=pl.all())
+
+    assert_frame_equal(result, actual, check_column_order=False)
diff --git a/tests/polars/functions/test_pivot_longer_spec_polars.py b/tests/polars/functions/test_pivot_longer_spec_polars.py
new file mode 100644
index 0000000..fcd0631
--- /dev/null
+++ b/tests/polars/functions/test_pivot_longer_spec_polars.py
@@ -0,0 +1,114 @@
+import polars as pl
+import pytest
+from polars.testing import assert_frame_equal
+
+from janitor.polars import pivot_longer_spec
+
+
+@pytest.fixture
+def df_checks():
+    """fixture dataframe"""
+    return pl.DataFrame(
+        {
+            "famid": [1, 1, 1, 2, 2, 2, 3, 3, 3],
+            "birth": [1, 2, 3, 1, 2, 3, 1, 2, 3],
+            "ht1": [2.8, 2.9, 2.2, 2, 1.8, 1.9, 2.2, 2.3, 2.1],
+            "ht2": [3.4, 3.8, 2.9, 3.2, 2.8, 2.4, 3.3, 3.4, 2.9],
+        }
+    )
+
+
+spec = {".name": ["ht1", "ht2"], ".value": ["ht", "ht"], "age": [1, 2]}
+spec = pl.DataFrame(spec)
+
+
+def test_spec_is_a_dataframe(df_checks):
+    """Raise Error if spec is not a DataFrame."""
+    with pytest.raises(
+        TypeError,
+        match="spec should be one of.+",
+    ):
+        df_checks.pipe(pivot_longer_spec, spec={".name": "name"})
+
+
+def test_spec_columns_has_dot_name(df_checks):
+    """Raise KeyError if '.name' not in spec's columns."""
+    with pytest.raises(
+        KeyError,
+        match="Kindly ensure the spec DataFrame has a `.name` column.",
+    ):
+        df_checks.pipe(
+            pivot_longer_spec,
+            spec=spec.rename({".name": "name"}),
+        )
+
+
+def test_spec_columns_has_dot_value(df_checks):
+    """Raise KeyError if '.value' not in spec's columns."""
+    with pytest.raises(
+        KeyError,
+        match="Kindly ensure the spec DataFrame has a `.value` column.",
+    ):
+        df_checks.pipe(
+            pivot_longer_spec,
+            spec=spec.rename({".value": "name"}),
+        )
+
+
+def test_spec_columns_dot_name_unique(df_checks):
+    """Raise ValueError if '.name' column is not unique."""
+    with pytest.raises(
+        ValueError, match="The labels in the `.name` column should be unique.+"
+    ):
+        df_checks.pipe(
+            pivot_longer_spec,
+            spec=spec.with_columns(pl.Series(".name", ["ht2", "ht2"])),
+        )
+
+
+def test_spec_columns_index(df_checks):
+    """Raise ValueError if the columns in spec already exist in the dataframe."""
+    with pytest.raises(
+        ValueError,
+        match=r"Labels \('birth',\) in the spec dataframe already exist.+",
+    ):
+        df_checks.pipe(
+            pivot_longer_spec,
+            spec=spec.with_columns(pl.Series("birth", ["ht1", "ht2"])),
+        )
+
+
+actual = [
+    {"famid": 1, "birth": 1, "age": 1, "ht": 2.8},
+    {"famid": 1, "birth": 1, "age": 2, "ht": 3.4},
+    {"famid": 1, "birth": 2, "age": 1, "ht": 2.9},
+    {"famid": 1, "birth": 2, "age": 2, "ht": 3.8},
+    {"famid": 1, "birth": 3, "age": 1, "ht": 2.2},
+    {"famid": 1, "birth": 3, "age": 2, "ht": 2.9},
+    {"famid": 2, "birth": 1, "age": 1, "ht": 2.0},
+    {"famid": 2, "birth": 1, "age": 2, "ht": 3.2},
+    {"famid": 2, "birth": 2, "age": 1, "ht": 1.8},
+    {"famid": 2, "birth": 2, "age": 2, "ht": 2.8},
+    {"famid": 2, "birth": 3, "age": 1, "ht": 1.9},
+    {"famid": 2, "birth": 3, "age": 2, "ht": 2.4},
+    {"famid": 3, "birth": 1, "age": 1, "ht": 2.2},
+    {"famid": 3, "birth": 1, "age": 2, "ht": 3.3},
+    {"famid": 3, "birth": 2, "age": 1, "ht": 2.3},
+    {"famid": 3, "birth": 2, "age": 2, "ht": 3.4},
+    {"famid": 3, "birth": 3, "age": 1, "ht": 2.1},
+    {"famid": 3, "birth": 3, "age": 2, "ht": 2.9},
+]
+actual = pl.DataFrame(actual).sort(by=pl.all())
+
+
+def test_pivot_longer_spec(df_checks):
+    """
+    Test output if a specification is passed.
+    """
+    expected = (
+        df_checks.pipe(pivot_longer_spec, spec=spec)
+        .select("famid", "birth", "age", "ht")
+        .sort(by=pl.all())
+    )
+
+    assert_frame_equal(actual, expected)