Repository: pyjanitor-devs/pyjanitor
Pull request #1033 (2022-03-02 14:18:37)

[ENH] Peformance improvement for `expand_grid`
# PR Description

Please describe the changes proposed in the pull request:

- Dump down into numpy arrays where possible
- Build a dataframe, as opposed to `pd.concat` for speed/some memory savings
- cascades to `complete`;
- patch for `complete` (sort=False is more efficient; `explicit` logic more efficient as well)

```py
# take tests with a pinch of salt
df1 = pd.DataFrame({"x":range(1,3), "y":[2,1]})
df1 = pd.concat([df1]*100)
df2 = pd.DataFrame({"x":[1,2,3],"y":[3,2,1]})
df2 = pd.concat([df2]*100)
df3 = pd.DataFrame({"x":[2,3],"y":["a","b"]})
df3 = pd.concat([df3]*100)

data = {"df1":df1, "df2":df2, "df3":df3}

------> dev
 %timeit jn.expand_grid(others = data) 
3.49 s ± 260 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%memit jn.expand_grid(others = data)
peak memory: 1936.24 MiB, increment: 1716.80 MiB


------> PR
 %timeit jn.expand_grid(others = data)
620 ms ± 58.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%memit jn.expand_grid(others = data)
peak memory: 905.20 MiB, increment: 763.66 MiB
```

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR improves `expand_grid`.**

# 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. [ ] 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. [ ] If you're not on the contributors list, add yourself to `AUTHORS.rst`.
<!-- We'd like to acknowledge your contributions! -->
3. [ ] 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-1033--pyjanitor.netlify.app

Patch:
diff --git a/.requirements/all.txt b/.requirements/all.txt
index da32c95..31d31da 100644
--- a/.requirements/all.txt
+++ b/.requirements/all.txt
@@ -1,5 +1,5 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/all.in
@@ -10,6 +10,8 @@ appdirs==1.4.4
     # via
     #   black
     #   virtualenv
+appnope==0.1.2
+    # via ipython
 async-generator==1.10
     # via nbclient
 attrs==20.2.0
@@ -24,9 +26,9 @@ babel==2.9.1
 backcall==0.2.0
     # via ipython
 biopython==1.78
-    # via -r biology.in
+    # via -r ./.requirements/biology.in
 black==20.8b1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 bleach==3.3.0
     # via nbconvert
 certifi==2020.6.20
@@ -45,7 +47,7 @@ colorama==0.4.4
 coverage==5.3
     # via pytest-cov
 darglint==1.5.5
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 decorator==4.4.2
     # via ipython
 defusedxml==0.6.0
@@ -64,10 +66,10 @@ filelock==3.0.12
     # via virtualenv
 flake8==3.8.4
     # via
-    #   -r dev.in
+    #   -r ./.requirements/dev.in
     #   pandas-vet
 hypothesis==5.37.3
-    # via -r testing.in
+    # via -r ./.requirements/testing.in
 identify==1.5.6
     # via pre-commit
 idna==2.10
@@ -77,15 +79,15 @@ imagesize==1.2.0
 iniconfig==1.1.1
     # via pytest
 interrogate==1.3.1
-    # via -r testing.in
+    # via -r ./.requirements/testing.in
 ipython==7.18.1
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 ipython-genutils==0.2.0
     # via
     #   nbformat
     #   traitlets
 isort==5.6.4
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 jedi==0.17.2
     # via ipython
 jinja2==2.11.3
@@ -113,11 +115,11 @@ mistune==0.8.4
 mpmath==1.1.0
     # via sympy
 multipledispatch==0.6.0
-    # via -r base.in
+    # via -r ./.requirements/base.in
 mypy-extensions==0.4.3
     # via black
 natsort==7.1.1
-    # via -r base.in
+    # via -r ./.requirements/base.in
 nbclient==0.5.1
     # via nbconvert
 nbconvert==6.0.7
@@ -128,7 +130,7 @@ nbformat==5.0.8
     #   nbconvert
     #   nbsphinx
 nbsphinx==0.7.1
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 nest-asyncio==1.4.1
     # via nbclient
 nodeenv==1.5.0
@@ -150,9 +152,9 @@ pandas==1.3.2
     #   pandas-flavor
     #   xarray
 pandas-flavor==0.2.0
-    # via -r base.in
+    # via -r ./.requirements/base.in
 pandas-vet==0.2.2
-    # via -r testing.in
+    # via -r ./.requirements/testing.in
 pandocfilters==1.4.2
     # via nbconvert
 parso==0.7.1
@@ -164,18 +166,18 @@ pexpect==4.8.0
 pickleshare==0.7.5
     # via ipython
 pip-tools==5.3.1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 pluggy==0.13.1
     # via pytest
 pre-commit==2.7.1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 prompt-toolkit==3.0.8
     # via ipython
 ptyprocess==0.6.0
     # via pexpect
 py==1.10.0
     # via
-    #   -r testing.in
+    #   -r ./.requirements/testing.in
     #   interrogate
     #   pytest
     #   pytest-forked
@@ -196,19 +198,19 @@ pyparsing==2.4.7
 pyrsistent==0.17.3
     # via jsonschema
 pyspark==3.0.1
-    # via -r spark.in
+    # via -r ./.requirements/spark.in
 pytest==6.1.1
     # via
-    #   -r testing.in
+    #   -r ./.requirements/testing.in
     #   pytest-cov
     #   pytest-forked
     #   pytest-xdist
 pytest-cov==2.10.1
-    # via -r testing.in
+    # via -r ./.requirements/testing.in
 pytest-forked==1.3.0
     # via pytest-xdist
 pytest-xdist==2.3.0
-    # via -r testing.in
+    # via -r ./.requirements/testing.in
 python-dateutil==2.8.1
     # via
     #   jupyter-client
@@ -225,8 +227,8 @@ regex==2020.10.15
     # via black
 requests==2.24.0
     # via sphinx
-scipy==1.7.1
-    # via -r base.in
+scipy==1.8.0
+    # via -r ./.requirements/base.in
 six==1.15.0
     # via
     #   bleach
@@ -242,14 +244,14 @@ sortedcontainers==2.2.2
     # via hypothesis
 sphinx==3.2.1
     # via
-    #   -r docs.in
+    #   -r ./.requirements/docs.in
     #   nbsphinx
 sphinxcontrib-applehelp==1.0.2
     # via sphinx
 sphinxcontrib-devhelp==1.0.2
     # via sphinx
 sphinxcontrib-fulltoc==1.2.0
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 sphinxcontrib-htmlhelp==1.0.3
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
@@ -273,7 +275,7 @@ toml==0.10.1
 tornado==6.0.4
     # via jupyter-client
 tqdm==4.50.2
-    # via -r chemistry.in
+    # via -r ./.requirements/chemistry.in
 traitlets==5.0.5
     # via
     #   ipython
@@ -288,7 +290,7 @@ typed-ast==1.4.1
 typing-extensions==3.7.4.3
     # via black
 unyt==2.8.0
-    # via -r engineering.in
+    # via -r ./.requirements/engineering.in
 urllib3==1.25.10
     # via requests
 virtualenv==20.0.35
diff --git a/.requirements/base.txt b/.requirements/base.txt
index 3cf6742..185a942 100644
--- a/.requirements/base.txt
+++ b/.requirements/base.txt
@@ -1,18 +1,36 @@
 #
-# This file is autogenerated by pip-compile with python 3.8
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/base.in
 #
-multipledispatch==0.6.0         # via -r ./.requirements/base.in
-natsort==7.0.1                  # via -r ./.requirements/base.in
-numpy==1.19.2                   # via pandas xarray
-pandas==1.1.3                   # via pandas-flavor xarray
-pandas-flavor==0.2.0            # via -r ./.requirements/base.in
-python-dateutil==2.8.1          # via pandas
-pytz==2020.1                    # via pandas
-six==1.15.0                     # via multipledispatch python-dateutil
-xarray==0.16.1                  # via pandas-flavor
+multipledispatch==0.6.0
+    # via -r ./.requirements/base.in
+natsort==7.0.1
+    # via -r ./.requirements/base.in
+numpy==1.19.2
+    # via
+    #   pandas
+    #   scipy
+    #   xarray
+pandas==1.1.3
+    # via
+    #   pandas-flavor
+    #   xarray
+pandas-flavor==0.2.0
+    # via -r ./.requirements/base.in
+python-dateutil==2.8.1
+    # via pandas
+pytz==2020.1
+    # via pandas
+scipy==1.8.0
+    # via -r ./.requirements/base.in
+six==1.15.0
+    # via
+    #   multipledispatch
+    #   python-dateutil
+xarray==0.16.1
+    # via pandas-flavor
 
 # The following packages are considered to be unsafe in a requirements file:
 # setuptools
diff --git a/.requirements/biology.txt b/.requirements/biology.txt
index 466ed3c..f91cb7a 100644
--- a/.requirements/biology.txt
+++ b/.requirements/biology.txt
@@ -1,8 +1,10 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/biology.in
 #
-biopython==1.78           # via -r ./.requirements/biology.in
-numpy==1.19.2             # via biopython
+biopython==1.78
+    # via -r ./.requirements/biology.in
+numpy==1.19.2
+    # via biopython
diff --git a/.requirements/chemistry.txt b/.requirements/chemistry.txt
index 91eb26d..bfb8140 100644
--- a/.requirements/chemistry.txt
+++ b/.requirements/chemistry.txt
@@ -1,7 +1,8 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/chemistry.in
 #
-tqdm==4.50.2              # via -r ./.requirements/chemistry.in
+tqdm==4.50.2
+    # via -r ./.requirements/chemistry.in
diff --git a/.requirements/dev.txt b/.requirements/dev.txt
index 91bde83..8f81267 100644
--- a/.requirements/dev.txt
+++ b/.requirements/dev.txt
@@ -1,5 +1,5 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/dev.in
@@ -9,7 +9,7 @@ appdirs==1.4.4
     #   black
     #   virtualenv
 black==20.8b1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 cfgv==3.2.0
     # via pre-commit
 click==7.1.2
@@ -17,17 +17,17 @@ click==7.1.2
     #   black
     #   pip-tools
 darglint==1.5.5
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 distlib==0.3.1
     # via virtualenv
 filelock==3.0.12
     # via virtualenv
 flake8==3.8.4
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 identify==1.5.6
     # via pre-commit
 isort==5.6.4
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 mccabe==0.6.1
     # via flake8
 mypy-extensions==0.4.3
@@ -37,9 +37,9 @@ nodeenv==1.5.0
 pathspec==0.8.0
     # via black
 pip-tools==5.3.1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 pre-commit==2.7.1
-    # via -r dev.in
+    # via -r ./.requirements/dev.in
 pycodestyle==2.6.0
     # via flake8
 pyflakes==2.2.0
diff --git a/.requirements/docs.txt b/.requirements/docs.txt
index 2ac5dfb..ac2b233 100644
--- a/.requirements/docs.txt
+++ b/.requirements/docs.txt
@@ -1,11 +1,13 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/docs.in
 #
 alabaster==0.7.12
     # via sphinx
+appnope==0.1.2
+    # via ipython
 async-generator==1.10
     # via nbclient
 attrs==20.2.0
@@ -15,7 +17,7 @@ babel==2.9.1
 backcall==0.2.0
     # via ipython
 biopython==1.78
-    # via -r biology.in
+    # via -r ./.requirements/biology.in
 bleach==3.3.0
     # via nbconvert
 certifi==2020.6.20
@@ -37,7 +39,7 @@ idna==2.10
 imagesize==1.2.0
     # via sphinx
 ipython==7.18.1
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 ipython-genutils==0.2.0
     # via
     #   nbformat
@@ -76,7 +78,7 @@ nbformat==5.0.8
     #   nbconvert
     #   nbsphinx
 nbsphinx==0.7.1
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 nest-asyncio==1.4.1
     # via nbclient
 numpy==1.19.2
@@ -112,7 +114,7 @@ pyparsing==2.4.7
 pyrsistent==0.17.3
     # via jsonschema
 pyspark==3.0.1
-    # via -r spark.in
+    # via -r ./.requirements/spark.in
 python-dateutil==2.8.1
     # via jupyter-client
 pytz==2020.1
@@ -131,14 +133,14 @@ snowballstemmer==2.0.0
     # via sphinx
 sphinx==3.2.1
     # via
-    #   -r docs.in
+    #   -r ./.requirements/docs.in
     #   nbsphinx
 sphinxcontrib-applehelp==1.0.2
     # via sphinx
 sphinxcontrib-devhelp==1.0.2
     # via sphinx
 sphinxcontrib-fulltoc==1.2.0
-    # via -r docs.in
+    # via -r ./.requirements/docs.in
 sphinxcontrib-htmlhelp==1.0.3
     # via sphinx
 sphinxcontrib-jsmath==1.0.1
@@ -154,7 +156,7 @@ testpath==0.4.4
 tornado==6.0.4
     # via jupyter-client
 tqdm==4.50.2
-    # via -r chemistry.in
+    # via -r ./.requirements/chemistry.in
 traitlets==5.0.5
     # via
     #   ipython
@@ -165,7 +167,7 @@ traitlets==5.0.5
     #   nbformat
     #   nbsphinx
 unyt==2.8.0
-    # via -r engineering.in
+    # via -r ./.requirements/engineering.in
 urllib3==1.25.10
     # via requests
 wcwidth==0.2.5
diff --git a/.requirements/engineering.txt b/.requirements/engineering.txt
index 3176a4a..51890d8 100644
--- a/.requirements/engineering.txt
+++ b/.requirements/engineering.txt
@@ -1,10 +1,14 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/engineering.in
 #
-mpmath==1.1.0             # via sympy
-numpy==1.19.2             # via unyt
-sympy==1.6.2              # via unyt
-unyt==2.8.0               # via -r ./.requirements/engineering.in
+mpmath==1.1.0
+    # via sympy
+numpy==1.19.2
+    # via unyt
+sympy==1.6.2
+    # via unyt
+unyt==2.8.0
+    # via -r ./.requirements/engineering.in
diff --git a/.requirements/spark.txt b/.requirements/spark.txt
index 32980ef..d230d51 100644
--- a/.requirements/spark.txt
+++ b/.requirements/spark.txt
@@ -1,8 +1,10 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/spark.in
 #
-py4j==0.10.9              # via pyspark
-pyspark==3.0.1            # via -r ./.requirements/spark.in
+py4j==0.10.9
+    # via pyspark
+pyspark==3.0.1
+    # via -r ./.requirements/spark.in
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29350c9..9b5d39f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@
 -   [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
 
 ## [v0.22.0] - 2021-11-21
 
diff --git a/environment-dev.yml b/environment-dev.yml
index df921a6..81ba463 100644
--- a/environment-dev.yml
+++ b/environment-dev.yml
@@ -21,7 +21,7 @@ dependencies:
   - make
   - mkdocs
   - mkdocs-material
-  - mkdocstrings
+  - mkdocstrings<0.18.0
   - missingno
   - multipledispatch
   - mypy
diff --git a/examples/notebooks/complete.ipynb b/examples/notebooks/complete.ipynb
index 2b8cfa4..8812708 100644
--- a/examples/notebooks/complete.ipynb
+++ b/examples/notebooks/complete.ipynb
@@ -2,55 +2,29 @@
  "cells": [
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Expose explicitly missing values with `complete`"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import pandas as pd\n",
     "import numpy as np \n",
-    "import janitor"
-   ],
-   "outputs": [],
-   "metadata": {}
+    "import janitor\n"
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 2,
-   "source": [
-    "# from http://imachordata.com/2016/02/05/you-complete-me/\n",
-    "df = pd.DataFrame(\n",
-    "        {\n",
-    "            \"Year\": [1999, 2000, 2004, 1999, 2004],\n",
-    "            \"Taxon\": [\n",
-    "                \"Saccharina\",\n",
-    "                \"Saccharina\",\n",
-    "                \"Saccharina\",\n",
-    "                \"Agarum\",\n",
-    "                \"Agarum\",\n",
-    "            ],\n",
-    "            \"Abundance\": [4, 5, 2, 1, 8],\n",
-    "        }\n",
-    "    )\n",
-    "\n",
-    "df"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   Year       Taxon  Abundance\n",
-       "0  1999  Saccharina          4\n",
-       "1  2000  Saccharina          5\n",
-       "2  2004  Saccharina          2\n",
-       "3  1999      Agarum          1\n",
-       "4  2004      Agarum          8"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -109,40 +83,54 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   Year       Taxon  Abundance\n",
+       "0  1999  Saccharina          4\n",
+       "1  2000  Saccharina          5\n",
+       "2  2004  Saccharina          2\n",
+       "3  1999      Agarum          1\n",
+       "4  2004      Agarum          8"
       ]
      },
+     "execution_count": 2,
      "metadata": {},
-     "execution_count": 2
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "# from http://imachordata.com/2016/02/05/you-complete-me/\n",
+    "df = pd.DataFrame(\n",
+    "        {\n",
+    "            \"Year\": [1999, 2000, 2004, 1999, 2004],\n",
+    "            \"Taxon\": [\n",
+    "                \"Saccharina\",\n",
+    "                \"Saccharina\",\n",
+    "                \"Saccharina\",\n",
+    "                \"Agarum\",\n",
+    "                \"Agarum\",\n",
+    "            ],\n",
+    "            \"Abundance\": [4, 5, 2, 1, 8],\n",
+    "        }\n",
+    "    )\n",
+    "\n",
+    "df"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "Note that Year 2000 and Agarum pairing is missing in the DataFrame above. Let’s make it explicit:"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 3,
-   "source": [
-    "df.complete('Year', 'Taxon')"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   Year       Taxon  Abundance\n",
-       "0  1999  Saccharina        4.0\n",
-       "1  2000  Saccharina        5.0\n",
-       "2  2004  Saccharina        2.0\n",
-       "3  1999      Agarum        1.0\n",
-       "4  2004      Agarum        8.0\n",
-       "5  2000      Agarum        NaN"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -176,65 +164,71 @@
        "    </tr>\n",
        "    <tr>\n",
        "      <th>1</th>\n",
-       "      <td>2000</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>5.0</td>\n",
+       "      <td>1999</td>\n",
+       "      <td>Agarum</td>\n",
+       "      <td>1.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>2</th>\n",
-       "      <td>2004</td>\n",
+       "      <td>2000</td>\n",
        "      <td>Saccharina</td>\n",
-       "      <td>2.0</td>\n",
+       "      <td>5.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>3</th>\n",
-       "      <td>1999</td>\n",
+       "      <td>2000</td>\n",
        "      <td>Agarum</td>\n",
-       "      <td>1.0</td>\n",
+       "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
        "      <td>2004</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>8.0</td>\n",
+       "      <td>Saccharina</td>\n",
+       "      <td>2.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>5</th>\n",
-       "      <td>2000</td>\n",
+       "      <td>2004</td>\n",
        "      <td>Agarum</td>\n",
-       "      <td>NaN</td>\n",
+       "      <td>8.0</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   Year       Taxon  Abundance\n",
+       "0  1999  Saccharina        4.0\n",
+       "1  1999      Agarum        1.0\n",
+       "2  2000  Saccharina        5.0\n",
+       "3  2000      Agarum        NaN\n",
+       "4  2004  Saccharina        2.0\n",
+       "5  2004      Agarum        8.0"
       ]
      },
+     "execution_count": 3,
      "metadata": {},
-     "execution_count": 3
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "df.complete('Year', 'Taxon')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "What if we wanted the explicit missing values for all the years from 1999 to 2004? Easy - simply pass a dictionary pairing the column name with the new values:"
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 4,
-   "source": [
-    "# A better viewing based on order\n",
-    "df.complete('Year', 'Taxon', sort = True)"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   Year       Taxon  Abundance\n",
-       "0  1999      Agarum        1.0\n",
-       "1  1999  Saccharina        4.0\n",
-       "2  2000      Agarum        NaN\n",
-       "3  2000  Saccharina        5.0\n",
-       "4  2004      Agarum        8.0\n",
-       "5  2004  Saccharina        2.0"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -286,188 +280,56 @@
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
-       "      <td>2004</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>8.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>5</th>\n",
-       "      <td>2004</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>2.0</td>\n",
-       "    </tr>\n",
-       "  </tbody>\n",
-       "</table>\n",
-       "</div>"
-      ]
-     },
-     "metadata": {},
-     "execution_count": 4
-    }
-   ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "markdown",
-   "source": [
-    "What if we wanted the explicit missing values for all the years from 1999 to 2004? Easy - simply pass a dictionary pairing the column name with the new values:"
-   ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 5,
-   "source": [
-    "new_year_values = {'Year': range(df.Year.min(), df.Year.max() + 1)}\n",
-    "\n",
-    "df.complete(new_year_values, \"Taxon\")"
-   ],
-   "outputs": [
-    {
-     "output_type": "execute_result",
-     "data": {
-      "text/plain": [
-       "    Year       Taxon  Abundance\n",
-       "0   1999  Saccharina        4.0\n",
-       "1   2000  Saccharina        5.0\n",
-       "2   2004  Saccharina        2.0\n",
-       "3   1999      Agarum        1.0\n",
-       "4   2004      Agarum        8.0\n",
-       "5   2000      Agarum        NaN\n",
-       "6   2001  Saccharina        NaN\n",
-       "7   2001      Agarum        NaN\n",
-       "8   2002  Saccharina        NaN\n",
-       "9   2002      Agarum        NaN\n",
-       "10  2003  Saccharina        NaN\n",
-       "11  2003      Agarum        NaN"
-      ],
-      "text/html": [
-       "<div>\n",
-       "<style scoped>\n",
-       "    .dataframe tbody tr th:only-of-type {\n",
-       "        vertical-align: middle;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe tbody tr th {\n",
-       "        vertical-align: top;\n",
-       "    }\n",
-       "\n",
-       "    .dataframe thead th {\n",
-       "        text-align: right;\n",
-       "    }\n",
-       "</style>\n",
-       "<table border=\"1\" class=\"dataframe\">\n",
-       "  <thead>\n",
-       "    <tr style=\"text-align: right;\">\n",
-       "      <th></th>\n",
-       "      <th>Year</th>\n",
-       "      <th>Taxon</th>\n",
-       "      <th>Abundance</th>\n",
-       "    </tr>\n",
-       "  </thead>\n",
-       "  <tbody>\n",
-       "    <tr>\n",
-       "      <th>0</th>\n",
-       "      <td>1999</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>4.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>1</th>\n",
-       "      <td>2000</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>5.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>2</th>\n",
-       "      <td>2004</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>2.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>3</th>\n",
-       "      <td>1999</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>1.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>4</th>\n",
-       "      <td>2004</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>8.0</td>\n",
-       "    </tr>\n",
-       "    <tr>\n",
-       "      <th>5</th>\n",
-       "      <td>2000</td>\n",
+       "      <td>2001</td>\n",
        "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>6</th>\n",
+       "      <th>5</th>\n",
        "      <td>2001</td>\n",
        "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>7</th>\n",
-       "      <td>2001</td>\n",
+       "      <th>6</th>\n",
+       "      <td>2002</td>\n",
        "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>8</th>\n",
+       "      <th>7</th>\n",
        "      <td>2002</td>\n",
        "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>9</th>\n",
-       "      <td>2002</td>\n",
+       "      <th>8</th>\n",
+       "      <td>2003</td>\n",
        "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>10</th>\n",
+       "      <th>9</th>\n",
        "      <td>2003</td>\n",
        "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
-       "      <th>11</th>\n",
-       "      <td>2003</td>\n",
+       "      <th>10</th>\n",
+       "      <td>2004</td>\n",
        "      <td>Agarum</td>\n",
-       "      <td>NaN</td>\n",
+       "      <td>8.0</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>11</th>\n",
+       "      <td>2004</td>\n",
+       "      <td>Saccharina</td>\n",
+       "      <td>2.0</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
        "</div>"
-      ]
-     },
-     "metadata": {},
-     "execution_count": 5
-    }
-   ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "markdown",
-   "source": [
-    "You can pass a callable as values in the dictionary:"
-   ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
-   "source": [
-    "new_year_values = lambda year: range(year.min(), year.max() + 1)\n",
-    "\n",
-    "df.complete({\"Year\": new_year_values}, \"Taxon\", sort = True)"
-   ],
-   "outputs": [
-    {
-     "output_type": "execute_result",
-     "data": {
+      ],
       "text/plain": [
        "    Year       Taxon  Abundance\n",
        "0   1999      Agarum        1.0\n",
@@ -482,7 +344,33 @@
        "9   2003  Saccharina        NaN\n",
        "10  2004      Agarum        8.0\n",
        "11  2004  Saccharina        2.0"
-      ],
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "new_year_values = {'Year': range(df.Year.min(), df.Year.max() + 1)}\n",
+    "\n",
+    "df.complete(new_year_values, \"Taxon\", sort = True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can pass a callable as values in the dictionary:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -511,117 +399,120 @@
        "    <tr>\n",
        "      <th>0</th>\n",
        "      <td>1999</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>1.0</td>\n",
+       "      <td>Saccharina</td>\n",
+       "      <td>4.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>1</th>\n",
        "      <td>1999</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>4.0</td>\n",
+       "      <td>Agarum</td>\n",
+       "      <td>1.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>2</th>\n",
        "      <td>2000</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>NaN</td>\n",
+       "      <td>Saccharina</td>\n",
+       "      <td>5.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>3</th>\n",
        "      <td>2000</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>5.0</td>\n",
+       "      <td>Agarum</td>\n",
+       "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
        "      <td>2001</td>\n",
-       "      <td>Agarum</td>\n",
+       "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>5</th>\n",
        "      <td>2001</td>\n",
-       "      <td>Saccharina</td>\n",
+       "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>6</th>\n",
        "      <td>2002</td>\n",
-       "      <td>Agarum</td>\n",
+       "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>7</th>\n",
        "      <td>2002</td>\n",
-       "      <td>Saccharina</td>\n",
+       "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>8</th>\n",
        "      <td>2003</td>\n",
-       "      <td>Agarum</td>\n",
+       "      <td>Saccharina</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>9</th>\n",
        "      <td>2003</td>\n",
-       "      <td>Saccharina</td>\n",
+       "      <td>Agarum</td>\n",
        "      <td>NaN</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>10</th>\n",
        "      <td>2004</td>\n",
-       "      <td>Agarum</td>\n",
-       "      <td>8.0</td>\n",
+       "      <td>Saccharina</td>\n",
+       "      <td>2.0</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>11</th>\n",
        "      <td>2004</td>\n",
-       "      <td>Saccharina</td>\n",
-       "      <td>2.0</td>\n",
+       "      <td>Agarum</td>\n",
+       "      <td>8.0</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "    Year       Taxon  Abundance\n",
+       "0   1999  Saccharina        4.0\n",
+       "1   1999      Agarum        1.0\n",
+       "2   2000  Saccharina        5.0\n",
+       "3   2000      Agarum        NaN\n",
+       "4   2001  Saccharina        NaN\n",
+       "5   2001      Agarum        NaN\n",
+       "6   2002  Saccharina        NaN\n",
+       "7   2002      Agarum        NaN\n",
+       "8   2003  Saccharina        NaN\n",
+       "9   2003      Agarum        NaN\n",
+       "10  2004  Saccharina        2.0\n",
+       "11  2004      Agarum        8.0"
       ]
      },
+     "execution_count": 5,
      "metadata": {},
-     "execution_count": 6
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "new_year_values = lambda year: range(year.min(), year.max() + 1)\n",
+    "\n",
+    "df.complete({\"Year\": new_year_values}, \"Taxon\")"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
-    "You can get explcit rows, based only on existing data:"
-   ],
-   "metadata": {}
+    "You can get explicit rows, based only on existing data:"
+   ]
   },
   {
    "cell_type": "code",
-   "execution_count": 7,
-   "source": [
-    "# https://stackoverflow.com/q/62266057/7175713\n",
-    "df = {\"Name\" : (\"Bob\", \"Bob\", \"Emma\"), \n",
-    "       \"Age\" : (23,23,78), \n",
-    "       \"Gender\" :(\"Male\", \"Male\", \"Female\"), \n",
-    "       \"Item\" : (\"house\", \"car\", \"house\"), \n",
-    "       \"Value\" : (5,1,3)\n",
-    "        }\n",
-    "df = pd.DataFrame(df)\n",
-    "df"
-   ],
+   "execution_count": 6,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   Name  Age  Gender   Item  Value\n",
-       "0   Bob   23    Male  house      5\n",
-       "1   Bob   23    Male    car      1\n",
-       "2  Emma   78  Female  house      3"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -676,38 +567,45 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   Name  Age  Gender   Item  Value\n",
+       "0   Bob   23    Male  house      5\n",
+       "1   Bob   23    Male    car      1\n",
+       "2  Emma   78  Female  house      3"
       ]
      },
+     "execution_count": 6,
      "metadata": {},
-     "execution_count": 7
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "# https://stackoverflow.com/q/62266057/7175713\n",
+    "df = {\"Name\" : (\"Bob\", \"Bob\", \"Emma\"), \n",
+    "       \"Age\" : (23,23,78), \n",
+    "       \"Gender\" :(\"Male\", \"Male\", \"Female\"), \n",
+    "       \"Item\" : (\"house\", \"car\", \"house\"), \n",
+    "       \"Value\" : (5,1,3)\n",
+    "        }\n",
+    "df = pd.DataFrame(df)\n",
+    "df"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "In the DataFrame above, there is no `car` Item value for the `Name`, `Age`, `Gender`  combination -> `(Emma, 78, Female)`. Pass `(Name, Age, Gender)` and `Item` to explicitly expose the missing row:"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
-   "execution_count": 8,
-   "source": [
-    "df.complete(('Name', 'Age', 'Gender'), 'Item')"
-   ],
+   "execution_count": 7,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   Name  Age  Gender   Item  Value\n",
-       "0   Bob   23    Male  house    5.0\n",
-       "1   Bob   23    Male    car    1.0\n",
-       "2  Emma   78  Female  house    3.0\n",
-       "3  Emma   78  Female    car    NaN"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -770,49 +668,38 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   Name  Age  Gender   Item  Value\n",
+       "0   Bob   23    Male  house    5.0\n",
+       "1   Bob   23    Male    car    1.0\n",
+       "2  Emma   78  Female  house    3.0\n",
+       "3  Emma   78  Female    car    NaN"
       ]
      },
+     "execution_count": 7,
      "metadata": {},
-     "execution_count": 8
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "df.complete(('Name', 'Age', 'Gender'), 'Item')"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "The example above showed how to expose missing rows on a group basis. There is also the option of exposing missing rows with the `by` parameter: "
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
-   "execution_count": 9,
-   "source": [
-    "df = pd.DataFrame(\n",
-    "        {\n",
-    "            \"state\": [\"CA\", \"CA\", \"HI\", \"HI\", \"HI\", \"NY\", \"NY\"],\n",
-    "            \"year\": [2010, 2013, 2010, 2012, 2016, 2009, 2013],\n",
-    "            \"value\": [1, 3, 1, 2, 3, 2, 5],\n",
-    "        }\n",
-    "    )\n",
-    "\n",
-    "df"
-   ],
+   "execution_count": 8,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "  state  year  value\n",
-       "0    CA  2010      1\n",
-       "1    CA  2013      3\n",
-       "2    HI  2010      1\n",
-       "3    HI  2012      2\n",
-       "4    HI  2016      3\n",
-       "5    NY  2009      2\n",
-       "6    NY  2013      5"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -883,56 +770,49 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "  state  year  value\n",
+       "0    CA  2010      1\n",
+       "1    CA  2013      3\n",
+       "2    HI  2010      1\n",
+       "3    HI  2012      2\n",
+       "4    HI  2016      3\n",
+       "5    NY  2009      2\n",
+       "6    NY  2013      5"
       ]
      },
+     "execution_count": 8,
      "metadata": {},
-     "execution_count": 9
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "df = pd.DataFrame(\n",
+    "        {\n",
+    "            \"state\": [\"CA\", \"CA\", \"HI\", \"HI\", \"HI\", \"NY\", \"NY\"],\n",
+    "            \"year\": [2010, 2013, 2010, 2012, 2016, 2009, 2013],\n",
+    "            \"value\": [1, 3, 1, 2, 3, 2, 5],\n",
+    "        }\n",
+    "    )\n",
+    "\n",
+    "df"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "Let's expose all the missing years, based on the minimum and maximum year, for each state:"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
-   "execution_count": 10,
-   "source": [
-    "result = df.complete(\n",
-    "            {'year': new_year_values},\n",
-    "            by='state',\n",
-    "            sort = True\n",
-    "        )\n",
-    "\n",
-    "result"
-   ],
+   "execution_count": 9,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   state  year  value\n",
-       "0     CA  2010    1.0\n",
-       "1     CA  2011    NaN\n",
-       "2     CA  2012    NaN\n",
-       "3     CA  2013    3.0\n",
-       "4     HI  2010    1.0\n",
-       "5     HI  2011    NaN\n",
-       "6     HI  2012    2.0\n",
-       "7     HI  2013    NaN\n",
-       "8     HI  2014    NaN\n",
-       "9     HI  2015    NaN\n",
-       "10    HI  2016    3.0\n",
-       "11    NY  2009    2.0\n",
-       "12    NY  2010    NaN\n",
-       "13    NY  2011    NaN\n",
-       "14    NY  2012    NaN\n",
-       "15    NY  2013    5.0"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -1057,50 +937,56 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   state  year  value\n",
+       "0     CA  2010    1.0\n",
+       "1     CA  2011    NaN\n",
+       "2     CA  2012    NaN\n",
+       "3     CA  2013    3.0\n",
+       "4     HI  2010    1.0\n",
+       "5     HI  2011    NaN\n",
+       "6     HI  2012    2.0\n",
+       "7     HI  2013    NaN\n",
+       "8     HI  2014    NaN\n",
+       "9     HI  2015    NaN\n",
+       "10    HI  2016    3.0\n",
+       "11    NY  2009    2.0\n",
+       "12    NY  2010    NaN\n",
+       "13    NY  2011    NaN\n",
+       "14    NY  2012    NaN\n",
+       "15    NY  2013    5.0"
       ]
      },
+     "execution_count": 9,
      "metadata": {},
-     "execution_count": 10
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "new_year_values = lambda year: range(year.min(), year.max() + 1)\n",
+    "\n",
+    "df.complete(\n",
+    "    {'year': new_year_values},\n",
+    "    by='state',\n",
+    ")\n",
+    "\n"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
-    "You can fill the nulls with Pandas' `fillna`:"
-   ],
-   "metadata": {}
+    "You can fill the nulls with `fill_value`:"
+   ]
   },
   {
    "cell_type": "code",
-   "execution_count": 11,
-   "source": [
-    "result.fillna(0, downcast = 'infer')"
-   ],
+   "execution_count": 10,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   state  year  value\n",
-       "0     CA  2010      1\n",
-       "1     CA  2011      0\n",
-       "2     CA  2012      0\n",
-       "3     CA  2013      3\n",
-       "4     HI  2010      1\n",
-       "5     HI  2011      0\n",
-       "6     HI  2012      2\n",
-       "7     HI  2013      0\n",
-       "8     HI  2014      0\n",
-       "9     HI  2015      0\n",
-       "10    HI  2016      3\n",
-       "11    NY  2009      2\n",
-       "12    NY  2010      0\n",
-       "13    NY  2011      0\n",
-       "14    NY  2012      0\n",
-       "15    NY  2013      5"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -1225,36 +1111,62 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   state  year  value\n",
+       "0     CA  2010      1\n",
+       "1     CA  2011      0\n",
+       "2     CA  2012      0\n",
+       "3     CA  2013      3\n",
+       "4     HI  2010      1\n",
+       "5     HI  2011      0\n",
+       "6     HI  2012      2\n",
+       "7     HI  2013      0\n",
+       "8     HI  2014      0\n",
+       "9     HI  2015      0\n",
+       "10    HI  2016      3\n",
+       "11    NY  2009      2\n",
+       "12    NY  2010      0\n",
+       "13    NY  2011      0\n",
+       "14    NY  2012      0\n",
+       "15    NY  2013      5"
       ]
      },
+     "execution_count": 10,
      "metadata": {},
-     "execution_count": 11
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "df.complete(\n",
+    "    {'year': new_year_values},\n",
+    "    by='state',\n",
+    "    fill_value = 0\n",
+    ")"
+   ]
   }
  ],
  "metadata": {
-  "orig_nbformat": 4,
+  "interpreter": {
+   "hash": "98b0a9b7b4eaaa670588a142fd0a9b87eaafe866f1db4228be72b4211d12040f"
+  },
+  "kernelspec": {
+   "display_name": "Python 3.8.10 64-bit ('base': conda)",
+   "name": "python3"
+  },
   "language_info": {
-   "name": "python",
-   "version": "3.8.10",
-   "mimetype": "text/x-python",
    "codemirror_mode": {
     "name": "ipython",
     "version": 3
    },
-   "pygments_lexer": "ipython3",
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
    "nbconvert_exporter": "python",
-   "file_extension": ".py"
-  },
-  "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('base': conda)"
+   "pygments_lexer": "ipython3",
+   "version": "3.9.9"
   },
-  "interpreter": {
-   "hash": "98b0a9b7b4eaaa670588a142fd0a9b87eaafe866f1db4228be72b4211d12040f"
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
diff --git a/examples/notebooks/expand_grid.ipynb b/examples/notebooks/expand_grid.ipynb
index 393650f..e4e57e3 100644
--- a/examples/notebooks/expand_grid.ipynb
+++ b/examples/notebooks/expand_grid.ipynb
@@ -2,13 +2,14 @@
  "cells": [
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "# Expand_grid : Create a dataframe from all combinations of inputs."
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "## Background \n",
     "\n",
@@ -16,44 +17,26 @@
     "Expand_grid creates a dataframe from a combination of all inputs. <br><br>One requirement is that a dictionary be provided. If a dataframe is provided, a key must be provided as well.\n",
     "\n",
     "Some of the examples used here are from tidyr's expand_grid page and from Pandas' cookbook.\n"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
    "source": [
     "import pandas as pd\n",
     "import numpy as np\n",
     "from janitor import expand_grid"
-   ],
-   "outputs": [],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 2,
-   "source": [
-    "data = {\"x\":[1,2,3], \"y\":[1,2]}\n",
-    "\n",
-    "result = expand_grid(others = data)\n",
-    "\n",
-    "result"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   x  y\n",
-       "   0  0\n",
-       "0  1  1\n",
-       "1  1  2\n",
-       "2  2  1\n",
-       "3  2  2\n",
-       "4  3  1\n",
-       "5  3  2"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -116,44 +99,38 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   x  y\n",
+       "   0  0\n",
+       "0  1  1\n",
+       "1  1  2\n",
+       "2  2  1\n",
+       "3  2  2\n",
+       "4  3  1\n",
+       "5  3  2"
       ]
      },
+     "execution_count": 2,
      "metadata": {},
-     "execution_count": 2
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "data = {\"x\":[1,2,3], \"y\":[1,2]}\n",
+    "\n",
+    "result = expand_grid(others = data)\n",
+    "\n",
+    "result"
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 3,
-   "source": [
-    "#combination of letters\n",
-    "\n",
-    "data = {\"l1\":list(\"abcde\"), \"l2\" : list(\"ABCDE\")}\n",
-    "\n",
-    "letters = expand_grid(others = data)\n",
-    "\n",
-    "letters.head(10)"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "  l1 l2\n",
-       "   0  0\n",
-       "0  a  A\n",
-       "1  a  B\n",
-       "2  a  C\n",
-       "3  a  D\n",
-       "4  a  E\n",
-       "5  b  A\n",
-       "6  b  B\n",
-       "7  b  C\n",
-       "8  b  D\n",
-       "9  b  E"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -236,47 +213,44 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "  l1 l2\n",
+       "   0  0\n",
+       "0  a  A\n",
+       "1  a  B\n",
+       "2  a  C\n",
+       "3  a  D\n",
+       "4  a  E\n",
+       "5  b  A\n",
+       "6  b  B\n",
+       "7  b  C\n",
+       "8  b  D\n",
+       "9  b  E"
       ]
      },
+     "execution_count": 3,
      "metadata": {},
-     "execution_count": 3
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 4,
    "source": [
+    "#combination of letters\n",
     "\n",
-    "data = {'height': [60, 70],\n",
-    "        'weight': [100, 140, 180],\n",
-    "        'sex': ['Male', 'Female']}\n",
+    "data = {\"l1\":list(\"abcde\"), \"l2\" : list(\"ABCDE\")}\n",
     "\n",
-    "result = expand_grid(others = data)\n",
+    "letters = expand_grid(others = data)\n",
     "\n",
-    "result"
-   ],
+    "letters.head(10)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   height weight     sex\n",
-       "        0      0       0\n",
-       "0      60    100    Male\n",
-       "1      60    100  Female\n",
-       "2      60    140    Male\n",
-       "3      60    140  Female\n",
-       "4      60    180    Male\n",
-       "5      60    180  Female\n",
-       "6      70    100    Male\n",
-       "7      70    100  Female\n",
-       "8      70    140    Male\n",
-       "9      70    140  Female\n",
-       "10     70    180    Male\n",
-       "11     70    180  Female"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -383,40 +357,47 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   height weight     sex\n",
+       "        0      0       0\n",
+       "0      60    100    Male\n",
+       "1      60    100  Female\n",
+       "2      60    140    Male\n",
+       "3      60    140  Female\n",
+       "4      60    180    Male\n",
+       "5      60    180  Female\n",
+       "6      70    100    Male\n",
+       "7      70    100  Female\n",
+       "8      70    140    Male\n",
+       "9      70    140  Female\n",
+       "10     70    180    Male\n",
+       "11     70    180  Female"
       ]
      },
+     "execution_count": 4,
      "metadata": {},
-     "execution_count": 4
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 5,
    "source": [
-    "#A dictionary of arrays\n",
-    "#Arrays can only have dimensions of 1 or 2\n",
     "\n",
-    "data = {\"x1\":np.array([[1,3],[2,4]]), \n",
-    "        \"x2\":np.array([[5,7],[6,8]])}\n",
+    "data = {'height': [60, 70],\n",
+    "        'weight': [100, 140, 180],\n",
+    "        'sex': ['Male', 'Female']}\n",
     "\n",
-    "result = expand_grid(others=data)\n",
+    "measurements = expand_grid(others = data)\n",
     "\n",
-    "result"
-   ],
+    "measurements"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "  x1    x2   \n",
-       "   0  1  0  1\n",
-       "0  1  3  5  7\n",
-       "1  1  3  6  8\n",
-       "2  2  4  5  7\n",
-       "3  2  4  6  8"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -479,45 +460,40 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "  x1    x2   \n",
+       "   0  1  0  1\n",
+       "0  1  3  5  7\n",
+       "1  1  3  6  8\n",
+       "2  2  4  5  7\n",
+       "3  2  4  6  8"
       ]
      },
+     "execution_count": 5,
      "metadata": {},
-     "execution_count": 5
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 6,
    "source": [
-    "#This shows how to method chain expand_grid\n",
-    "#to an existing dataframe\n",
-    "\n",
-    "df = pd.DataFrame({\"x\":[1,2], \"y\":[2,1]})\n",
-    "data = {\"z\":[1,2,3]}\n",
+    "# A dictionary of arrays\n",
+    "# Arrays can only have dimensions of 1 or 2\n",
     "\n",
-    "#a key has to be passed in for the dataframe\n",
-    "#this is added to the column name of the dataframe\n",
+    "data = {\"x1\":np.array([[1,3],[2,4]]), \n",
+    "        \"x2\":np.array([[5,7],[6,8]])}\n",
     "\n",
-    "result = df.expand_grid(df_key=\"df\",others = data)\n",
+    "result = expand_grid(others=data)\n",
     "\n",
     "result"
-   ],
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "  df     z\n",
-       "   x  y  0\n",
-       "0  1  2  1\n",
-       "1  1  2  2\n",
-       "2  1  2  3\n",
-       "3  2  1  1\n",
-       "4  2  1  2\n",
-       "5  2  1  3"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -587,53 +563,45 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "  df     z\n",
+       "   x  y  0\n",
+       "0  1  2  1\n",
+       "1  1  2  2\n",
+       "2  1  2  3\n",
+       "3  2  1  1\n",
+       "4  2  1  2\n",
+       "5  2  1  3"
       ]
      },
+     "execution_count": 6,
      "metadata": {},
-     "execution_count": 6
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
-  },
-  {
-   "cell_type": "code",
-   "execution_count": 7,
    "source": [
+    "# This shows how to method chain expand_grid\n",
+    "# to an existing dataframe\n",
     "\n",
-    "# expand_grid can work on multiple dataframes\n",
-    "# Ensure that there are keys\n",
-    "# for each dataframe in the dictionary\n",
-    "\n",
-    "df1 = pd.DataFrame({\"x\":range(1,3), \"y\":[2,1]})\n",
-    "df2 = pd.DataFrame({\"x\":[1,2,3],\"y\":[3,2,1]})\n",
-    "df3 = pd.DataFrame({\"x\":[2,3],\"y\":[\"a\",\"b\"]})\n",
+    "df = pd.DataFrame({\"x\":[1,2], \"y\":[2,1]})\n",
+    "data = {\"z\":[1,2,3]}\n",
     "\n",
-    "data = {\"df1\":df1, \"df2\":df2, \"df3\":df3}\n",
+    "# a key has to be passed in for the dataframe\n",
+    "# this is added to the column name of the dataframe\n",
     "\n",
-    "result = expand_grid(others=data)\n",
+    "result = df.expand_grid(df_key=\"df\",others = data)\n",
     "\n",
     "result"
-   ],
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "   df1    df2    df3   \n",
-       "     x  y   x  y   x  y\n",
-       "0    1  2   1  3   2  a\n",
-       "1    1  2   1  3   3  b\n",
-       "2    1  2   2  2   2  a\n",
-       "3    1  2   2  2   3  b\n",
-       "4    1  2   3  1   2  a\n",
-       "5    1  2   3  1   3  b\n",
-       "6    2  1   1  3   2  a\n",
-       "7    2  1   1  3   3  b\n",
-       "8    2  1   2  2   2  a\n",
-       "9    2  1   2  2   3  b\n",
-       "10   2  1   3  1   2  a\n",
-       "11   2  1   3  1   3  b"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -659,8 +627,8 @@
        "    </tr>\n",
        "    <tr>\n",
        "      <th></th>\n",
-       "      <th>x</th>\n",
-       "      <th>y</th>\n",
+       "      <th>x_y</th>\n",
+       "      <th>y_x</th>\n",
        "      <th>x</th>\n",
        "      <th>y</th>\n",
        "      <th>x</th>\n",
@@ -779,46 +747,60 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "   df1     df2    df3   \n",
+       "   x_y y_x   x  y   x  y\n",
+       "0    1   2   1  3   2  a\n",
+       "1    1   2   1  3   3  b\n",
+       "2    1   2   2  2   2  a\n",
+       "3    1   2   2  2   3  b\n",
+       "4    1   2   3  1   2  a\n",
+       "5    1   2   3  1   3  b\n",
+       "6    2   1   1  3   2  a\n",
+       "7    2   1   1  3   3  b\n",
+       "8    2   1   2  2   2  a\n",
+       "9    2   1   2  2   3  b\n",
+       "10   2   1   3  1   2  a\n",
+       "11   2   1   3  1   3  b"
       ]
      },
+     "execution_count": 7,
      "metadata": {},
-     "execution_count": 7
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "\n",
+    "# expand_grid can work on multiple dataframes\n",
+    "# Ensure that there are keys\n",
+    "# for each dataframe in the dictionary\n",
+    "\n",
+    "df1 = pd.DataFrame({(\"x\", \"y\"):range(1,3), (\"y\", \"x\"):[2,1]})\n",
+    "df2 = pd.DataFrame({\"x\":[1,2,3],\"y\":[3,2,1]})\n",
+    "df3 = pd.DataFrame({\"x\":[2,3],\"y\":[\"a\",\"b\"]})\n",
+    "\n",
+    "data = {\"df1\":df1, \"df2\":df2, \"df3\":df3}\n",
+    "\n",
+    "result = expand_grid(others=data)\n",
+    "\n",
+    "result"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "Columns can be flattened with pyjanitor's `collapse_levels`:"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 8,
-   "source": [
-    "result.collapse_levels()"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "    df1_x  df1_y  df2_x  df2_y  df3_x df3_y\n",
-       "0       1      2      1      3      2     a\n",
-       "1       1      2      1      3      3     b\n",
-       "2       1      2      2      2      2     a\n",
-       "3       1      2      2      2      3     b\n",
-       "4       1      2      3      1      2     a\n",
-       "5       1      2      3      1      3     b\n",
-       "6       2      1      1      3      2     a\n",
-       "7       2      1      1      3      3     b\n",
-       "8       2      1      2      2      2     a\n",
-       "9       2      1      2      2      3     b\n",
-       "10      2      1      3      1      2     a\n",
-       "11      2      1      3      1      3     b"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -838,8 +820,8 @@
        "  <thead>\n",
        "    <tr style=\"text-align: right;\">\n",
        "      <th></th>\n",
-       "      <th>df1_x</th>\n",
-       "      <th>df1_y</th>\n",
+       "      <th>df1_x_y</th>\n",
+       "      <th>df1_y_x</th>\n",
        "      <th>df2_x</th>\n",
        "      <th>df2_y</th>\n",
        "      <th>df3_x</th>\n",
@@ -958,44 +940,46 @@
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "    df1_x_y  df1_y_x  df2_x  df2_y  df3_x df3_y\n",
+       "0         1        2      1      3      2     a\n",
+       "1         1        2      1      3      3     b\n",
+       "2         1        2      2      2      2     a\n",
+       "3         1        2      2      2      3     b\n",
+       "4         1        2      3      1      2     a\n",
+       "5         1        2      3      1      3     b\n",
+       "6         2        1      1      3      2     a\n",
+       "7         2        1      1      3      3     b\n",
+       "8         2        1      2      2      2     a\n",
+       "9         2        1      2      2      3     b\n",
+       "10        2        1      3      1      2     a\n",
+       "11        2        1      3      1      3     b"
       ]
      },
+     "execution_count": 8,
      "metadata": {},
-     "execution_count": 8
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "result.collapse_levels()"
+   ]
   },
   {
    "cell_type": "markdown",
+   "metadata": {},
    "source": [
     "Or a level dropped with Pandas' `droplevel` method:"
-   ],
-   "metadata": {}
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": 9,
-   "source": [
-    "letters.droplevel(level = -1, axis = 'columns').head(10)"
-   ],
+   "metadata": {},
    "outputs": [
     {
-     "output_type": "execute_result",
      "data": {
-      "text/plain": [
-       "  l1 l2\n",
-       "0  a  A\n",
-       "1  a  B\n",
-       "2  a  C\n",
-       "3  a  D\n",
-       "4  a  E\n",
-       "5  b  A\n",
-       "6  b  B\n",
-       "7  b  C\n",
-       "8  b  D\n",
-       "9  b  E"
-      ],
       "text/html": [
        "<div>\n",
        "<style scoped>\n",
@@ -1015,77 +999,121 @@
        "  <thead>\n",
        "    <tr style=\"text-align: right;\">\n",
        "      <th></th>\n",
-       "      <th>l1</th>\n",
-       "      <th>l2</th>\n",
+       "      <th>height</th>\n",
+       "      <th>weight</th>\n",
+       "      <th>sex</th>\n",
        "    </tr>\n",
        "  </thead>\n",
        "  <tbody>\n",
        "    <tr>\n",
        "      <th>0</th>\n",
-       "      <td>a</td>\n",
-       "      <td>A</td>\n",
+       "      <td>60</td>\n",
+       "      <td>100</td>\n",
+       "      <td>Male</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>1</th>\n",
-       "      <td>a</td>\n",
-       "      <td>B</td>\n",
+       "      <td>60</td>\n",
+       "      <td>100</td>\n",
+       "      <td>Female</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>2</th>\n",
-       "      <td>a</td>\n",
-       "      <td>C</td>\n",
+       "      <td>60</td>\n",
+       "      <td>140</td>\n",
+       "      <td>Male</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>3</th>\n",
-       "      <td>a</td>\n",
-       "      <td>D</td>\n",
+       "      <td>60</td>\n",
+       "      <td>140</td>\n",
+       "      <td>Female</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>4</th>\n",
-       "      <td>a</td>\n",
-       "      <td>E</td>\n",
+       "      <td>60</td>\n",
+       "      <td>180</td>\n",
+       "      <td>Male</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>5</th>\n",
-       "      <td>b</td>\n",
-       "      <td>A</td>\n",
+       "      <td>60</td>\n",
+       "      <td>180</td>\n",
+       "      <td>Female</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>6</th>\n",
-       "      <td>b</td>\n",
-       "      <td>B</td>\n",
+       "      <td>70</td>\n",
+       "      <td>100</td>\n",
+       "      <td>Male</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>7</th>\n",
-       "      <td>b</td>\n",
-       "      <td>C</td>\n",
+       "      <td>70</td>\n",
+       "      <td>100</td>\n",
+       "      <td>Female</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>8</th>\n",
-       "      <td>b</td>\n",
-       "      <td>D</td>\n",
+       "      <td>70</td>\n",
+       "      <td>140</td>\n",
+       "      <td>Male</td>\n",
        "    </tr>\n",
        "    <tr>\n",
        "      <th>9</th>\n",
-       "      <td>b</td>\n",
-       "      <td>E</td>\n",
+       "      <td>70</td>\n",
+       "      <td>140</td>\n",
+       "      <td>Female</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>10</th>\n",
+       "      <td>70</td>\n",
+       "      <td>180</td>\n",
+       "      <td>Male</td>\n",
+       "    </tr>\n",
+       "    <tr>\n",
+       "      <th>11</th>\n",
+       "      <td>70</td>\n",
+       "      <td>180</td>\n",
+       "      <td>Female</td>\n",
        "    </tr>\n",
        "  </tbody>\n",
        "</table>\n",
        "</div>"
+      ],
+      "text/plain": [
+       "    height  weight     sex\n",
+       "0       60     100    Male\n",
+       "1       60     100  Female\n",
+       "2       60     140    Male\n",
+       "3       60     140  Female\n",
+       "4       60     180    Male\n",
+       "5       60     180  Female\n",
+       "6       70     100    Male\n",
+       "7       70     100  Female\n",
+       "8       70     140    Male\n",
+       "9       70     140  Female\n",
+       "10      70     180    Male\n",
+       "11      70     180  Female"
       ]
      },
+     "execution_count": 9,
      "metadata": {},
-     "execution_count": 9
+     "output_type": "execute_result"
     }
    ],
-   "metadata": {}
+   "source": [
+    "measurements.droplevel(level = -1, axis = 'columns')"
+   ]
   }
  ],
  "metadata": {
+  "interpreter": {
+   "hash": "98b0a9b7b4eaaa670588a142fd0a9b87eaafe866f1db4228be72b4211d12040f"
+  },
   "kernelspec": {
-   "name": "python3",
-   "display_name": "Python 3.8.10 64-bit ('base': conda)"
+   "display_name": "Python 3.8.10 64-bit ('base': conda)",
+   "name": "python3"
   },
   "language_info": {
    "codemirror_mode": {
@@ -1097,12 +1125,9 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.10"
+   "version": "3.9.9"
   },
-  "orig_nbformat": 2,
-  "interpreter": {
-   "hash": "98b0a9b7b4eaaa670588a142fd0a9b87eaafe866f1db4228be72b4211d12040f"
-  }
+  "orig_nbformat": 2
  },
  "nbformat": 4,
  "nbformat_minor": 2
diff --git a/janitor/functions/complete.py b/janitor/functions/complete.py
index cad930a..eb88de0 100644
--- a/janitor/functions/complete.py
+++ b/janitor/functions/complete.py
@@ -1,9 +1,10 @@
 from typing import Optional, Union, List, Tuple, Dict, Any
 from pandas.core.common import apply_if_callable
+from pandas.core.construction import extract_array
 import pandas_flavor as pf
 import pandas as pd
 import functools
-from pandas.api.types import is_list_like, is_scalar
+from pandas.api.types import is_list_like, is_scalar, is_categorical_dtype
 
 from janitor.utils import check, check_column
 
@@ -132,15 +133,14 @@ def complete(
         ...     sort=True
         ... )
            group  item_id item_name  value1  value2
-        0      1        1         a     1.0       4
-        1      1        2         a     0.0      99
-        2      1        2         b     3.0       6
-        3      1        3         b     0.0      99
-        4      2        1         a     0.0      99
-        5      2        2         a     NaN       5
-        6      2        2         b     0.0      99
-        7      2        3         b     4.0       7
-
+        0      1        1         a     1.0     4.0
+        1      1        2         a     0.0    99.0
+        2      1        2         b     3.0     6.0
+        3      1        3         b     0.0    99.0
+        4      2        1         a     0.0    99.0
+        5      2        2         a     NaN     5.0
+        6      2        2         b     0.0    99.0
+        7      2        3         b     4.0     7.0
 
     :param df: A pandas DataFrame.
     :param *columns: This refers to the columns to be
@@ -199,7 +199,7 @@ def _computations_complete(
             break
 
     # nothing to 'complete' here
-    if all_strings and len(columns) == 1:
+    if (all_strings and len(columns) == 1) or df.empty:
         return df
 
     # under the right conditions, stack/unstack can be faster
@@ -212,135 +212,174 @@ def _computations_complete(
     # trade-off for the simplicity of merge is not so bad
     # of course there could be a better way ...
     if by is None:
-        uniques = _generic_complete(df, columns, all_strings)
+        uniques = _generic_complete(df, columns, all_strings, sort)
     else:
         uniques = df.groupby(by)
-        uniques = uniques.apply(_generic_complete, columns, all_strings)
+        uniques = uniques.apply(_generic_complete, columns, all_strings, sort)
         uniques = uniques.droplevel(-1)
         column_checker = by + column_checker
-    if fill_value is None:
-        return df.merge(
-            uniques, on=column_checker, how="outer", sort=sort, copy=False
-        )
-    # if fill_value is present
-    if is_scalar(fill_value):
-        # faster when fillna operates on a Series basis
-        fill_value = {col: fill_value for col in df}
-
-    if explicit:
-        return df.merge(
-            uniques, on=column_checker, how="outer", sort=sort, copy=False
-        ).fillna(fill_value, downcast="infer")
-    # keep only columns that are not part of column_checker
-    # IOW, we are excluding columns that were not used
-    # to generate the combinations
-    fill_value = {
-        col: value
-        for col, value in fill_value.items()
-        if col not in column_checker
-    }
-    if not fill_value:  # there is nothing to fill
-        return df.merge(
-            uniques, on=column_checker, how="outer", sort=sort, copy=False
-        )
-
-    # when explicit is False
-    # filter out rows from `unique` that already exist in the parent dataframe
-    # fill the null values in the trimmed `unique`
-    # and merge back to the main dataframe
 
-    # to get a name that does not exist in the columns
-    indicator = "".join(df.columns)
-    trimmed = df.loc(axis=1)[column_checker]
-    uniques = uniques.merge(
-        trimmed, how="left", sort=False, copy=False, indicator=indicator
+    columns = df.columns
+    indicator = False
+    if fill_value is not None and not explicit:
+        # to get a name that does not exist in the columns
+        indicator = "".join(columns)
+    df = pd.merge(
+        uniques,
+        df,
+        how="outer",
+        on=column_checker,
+        copy=False,
+        sort=False,
+        indicator=indicator,
     )
-    trimmed = uniques.iloc(axis=1)[-1] == "left_only"
-    uniques = uniques.loc[trimmed, column_checker]
-    trimmed = None
-    indicator = None
-    # iteration used here, instead of assign (which is also a for loop),
-    # to cater for scenarios where the column_name is not a string
-    # assign only works with keys that are strings
-    for column_name, value in fill_value.items():
-        uniques[column_name] = value
-    df = pd.concat([df, uniques], sort=False, copy=False, axis="index")
-    if sort:
-        df = df.sort_values(column_checker)
-    df.index = range(len(df))
+
+    if fill_value is not None:
+        if is_scalar(fill_value):
+            # faster when fillna operates on a Series basis
+            fill_value = {
+                col: fill_value for col in columns if df[col].hasnans
+            }
+        if explicit:
+            df = df.fillna(fill_value, downcast="infer")
+        else:
+            # keep only columns that are not part of column_checker
+            # IOW, we are excluding columns that were not used
+            # to generate the combinations
+            fill_value = {
+                col: value
+                for col, value in fill_value.items()
+                if col not in column_checker
+            }
+            if fill_value:
+                # when explicit is False
+                # use the indicator parameter to identify rows
+                # for `left_only`, and fill the relevant columns in fill_value
+                # with the associated value.
+                boolean_filter = df.loc[:, indicator] == "left_only"
+                df = df.drop(columns=indicator)
+                # iteration used here,
+                # instead of assign (which is also a for loop),
+                # to cater for scenarios where the column_name is not a string
+                # assign only works with keys that are strings
+                # Also, the output wil be floats (for numeric types),
+                # even if all the columns could be integers
+                # user can always convert to int if required
+                for column_name, value in fill_value.items():
+                    # for categorical dtypes, set the categories first
+                    if is_categorical_dtype(df[column_name]):
+                        df[column_name] = df[column_name].cat.add_categories(
+                            [value]
+                        )
+                    df.loc[boolean_filter, column_name] = value
+
+    if not df.columns.equals(columns):
+        return df.reindex(columns=columns)
     return df
 
 
-def _generic_complete(df: pd.DataFrame, columns: list, all_strings: bool):
+def _generic_complete(
+    df: pd.DataFrame, columns: list, all_strings: bool, sort: bool
+):
     """
     Generate cartesian product for `_computations_complete`.
 
-    Returns a Series or DataFrame, with no duplicates.
+    Returns a DataFrame, with no duplicates.
     """
     if all_strings:
-        uniques = {col: df[col].unique() for col in columns}
+        if sort:
+            uniques = {}
+            for col in columns:
+                column = extract_array(df[col], extract_numpy=True)
+                _, column = pd.factorize(column, sort=sort)
+                uniques[col] = column
+        else:
+            uniques = {col: df[col].unique() for col in columns}
         uniques = _computations_expand_grid(uniques)
-        uniques = uniques.droplevel(level=-1, axis="columns")
+        uniques.columns = columns
         return uniques
 
     uniques = {}
+    df_columns = []
     for index, column in enumerate(columns):
+        if not isinstance(column, str):
+            df_columns.extend(column)
+        else:
+            df_columns.append(column)
         if isinstance(column, dict):
-            column = _complete_column(column, df)
+            column = _complete_column(column, df, sort)
             uniques = {**uniques, **column}
         else:
-            uniques[index] = _complete_column(column, df)
+            uniques[index] = _complete_column(column, df, sort)
 
     if len(uniques) == 1:
         _, uniques = uniques.popitem()
         return uniques.to_frame()
 
     uniques = _computations_expand_grid(uniques)
-    return uniques.droplevel(level=0, axis="columns")
+    uniques.columns = df_columns
+    return uniques
 
 
 @functools.singledispatch
-def _complete_column(column: str, df):
+def _complete_column(column: str, df, sort):
     """
     Args:
         column : str/list/dict
         df: Pandas DataFrame
+        sort: whether or not to sort the Series.
 
     A Pandas Series/DataFrame with no duplicates,
-    or a list of unique Pandas Series is returned.
+    or a dictionary of unique Pandas Series is returned.
     """
+    # the cost of checking uniqueness is expensive,
+    # especially for large data
+    # dirty tests also show that drop_duplicates
+    # is faster than pd.unique for fairly large data
+
     column = df[column]
+    dupes = column.duplicated()
+
+    if dupes.any():
+        column = column[~dupes]
+
+    if sort and not column.is_monotonic_increasing:
+        column = column.sort_values()
 
-    if not column.is_unique:
-        return column.drop_duplicates()
     return column
 
 
 @_complete_column.register(list)  # noqa: F811
-def _sub_complete_column(column, df):  # noqa: F811
+def _sub_complete_column(column, df, sort):  # noqa: F811
     """
     Args:
         column : list
         df: Pandas DataFrame
+        sort: whether or not to sort the DataFrame.
 
     Returns:
         Pandas DataFrame
     """
 
-    column = df.loc[:, column]
+    outcome = df.loc[:, column]
+    dupes = outcome.duplicated()
 
-    if column.duplicated().any(axis=None):
-        return column.drop_duplicates()
+    if dupes.any():
+        outcome = outcome.loc[~dupes]
 
-    return column
+    if sort:
+        outcome = outcome.sort_values(by=column)
+
+    return outcome
 
 
 @_complete_column.register(dict)  # noqa: F811
-def _sub_complete_column(column, df):  # noqa: F811
+def _sub_complete_column(column, df, sort):  # noqa: F811
     """
     Args:
         column : dictionary
         df: Pandas DataFrame
+        sort: whether or not to sort the Series.
 
     Returns:
         A dictionary of unique pandas Series.
@@ -371,8 +410,13 @@ def _sub_complete_column(column, df):  # noqa: F811
         if not isinstance(arr, pd.Series):
             arr = pd.Series(arr)
 
-        if not arr.is_unique:
-            arr = arr.drop_duplicates()
+        dupes = arr.duplicated()
+
+        if dupes.any():
+            arr = arr[~dupes]
+
+        if sort and not arr.is_monotonic_increasing:
+            arr = arr.sort_values()
 
         arr.name = key
 
diff --git a/janitor/functions/encode_categorical.py b/janitor/functions/encode_categorical.py
index 016b61e..0800ab3 100644
--- a/janitor/functions/encode_categorical.py
+++ b/janitor/functions/encode_categorical.py
@@ -1,10 +1,12 @@
+import warnings
+from enum import Enum
 from typing import Hashable, Iterable, Union
+
 import pandas_flavor as pf
 import pandas as pd
 from pandas.api.types import is_list_like
-import warnings
+
 from janitor.utils import check, check_column, deprecated_alias
-from enum import Enum
 
 
 @pf.register_dataframe_method
@@ -90,17 +92,15 @@ def encode_categorical(
         >>> enc_df["foo"].cat.ordered
         True
 
-
-
     :param df: A pandas DataFrame object.
     :param column_names: A column name or an iterable (list or tuple)
         of column names.
-    :param kwargs: A mapping from column name to either `None`,
-        `sort` or `appearance`, or a 1-D array. This is useful
+    :param **kwargs: A mapping from column name to either `None`,
+        `'sort'` or `'appearance'`, or a 1-D array. This is useful
         in creating categorical columns that are ordered, or
         if the user needs to explicitly specify the categories.
     :returns: A pandas DataFrame.
-    :raises ValueError: if both `column_names` and `kwargs` are provided.
+    :raises ValueError: If both `column_names` and `kwargs` are provided.
     """  # noqa: E501
 
     if all((column_names, kwargs)):
@@ -112,13 +112,11 @@ def encode_categorical(
     # or user supplies specific categories to create the categorical
     if column_names is not None:
         check("column_names", column_names, [list, tuple, Hashable])
-        if isinstance(column_names, (list, tuple)):
-            check_column(df, column_names)
-            dtypes = {col: "category" for col in column_names}
-            return df.astype(dtypes)
         if isinstance(column_names, Hashable):
-            check_column(df, [column_names])
-            return df.astype({column_names: "category"})
+            column_names = [column_names]
+        check_column(df, column_names)
+        dtypes = {col: "category" for col in column_names}
+        return df.astype(dtypes)
 
     return _computations_as_categorical(df, **kwargs)
 
@@ -167,21 +165,20 @@ def _as_categorical_checks(df: pd.DataFrame, **kwargs) -> dict:
     This function raises errors if columns in `kwargs` are
     absent from the dataframe's columns.
     It also raises errors if the value in `kwargs`
-    is not a string (`appearance` or `sort`), or a 1D array.
+    is not a string (`'appearance'` or `'sort'`), or a 1D array.
 
     This function is executed before proceeding to the computation phase.
 
     If all checks pass, a dictionary of column names and value is returned.
 
     :param df: The pandas DataFrame object.
-    :param kwargs: A pairing of column name and value.
+    :param **kwargs: A pairing of column name and value.
     :returns: A dictionary.
     :raises TypeError: If `value` is not a 1-D array, or a string.
     :raises ValueError: If `value` is a 1-D array, and contains nulls,
         or is non-unique.
     """
 
-    # column checks
     check_column(df, kwargs)
 
     categories_dict = {}
@@ -255,7 +252,7 @@ def _as_categorical_checks(df: pd.DataFrame, **kwargs) -> dict:
             category_order_types = {ent.value for ent in _CategoryOrder}
             if value.lower() not in category_order_types:
                 raise ValueError(
-                    "argument should be one of `appearance` or `sort`."
+                    "Argument should be one of 'appearance' or 'sort'."
                 )
 
         categories_dict[column_name] = value
diff --git a/janitor/functions/expand_grid.py b/janitor/functions/expand_grid.py
index f69e134..f263f1f 100644
--- a/janitor/functions/expand_grid.py
+++ b/janitor/functions/expand_grid.py
@@ -28,13 +28,12 @@ def expand_grid(
     Data types are preserved in this function,
     including pandas' extension array dtypes.
 
-    The output will always be a DataFrame, usually a MultiIndex,
-    with the keys of the `others` dictionary serving as
-    the top level columns.
+    The output will always be a DataFrame, usually with a MultiIndex column,
+    with the keys of the `others` dictionary serving as the top level columns.
 
-    If a DataFrame with MultiIndex columns is part of the arguments in
-    `others`, the columns are flattened, before the final
-    cartesian DataFrame is generated.
+    If a DataFrame with MultiIndex columns
+    is part of the arguments in `others`,
+    the columns are flattened, before the final DataFrame is generated.
 
     If a pandas Series/DataFrame is passed, and has a labeled index, or
     a MultiIndex index, the index is discarded; the final DataFrame
diff --git a/janitor/functions/factorize_columns.py b/janitor/functions/factorize_columns.py
index 6d340a7..1ecdf88 100644
--- a/janitor/functions/factorize_columns.py
+++ b/janitor/functions/factorize_columns.py
@@ -1,3 +1,4 @@
+"""Implementation of the `factorize_columns` function"""
 from typing import Hashable, Iterable, Union
 import pandas_flavor as pf
 import pandas as pd
@@ -13,52 +14,51 @@ def factorize_columns(
     **kwargs,
 ) -> pd.DataFrame:
     """
-    Converts labels into numerical data
+    Converts labels into numerical data.
 
     This method will create a new column with the string `_enc` appended
     after the original column's name.
     This can be overriden with the suffix parameter.
 
-    Internally this method uses pandas `factorize` method.
+    Internally, this method uses pandas `factorize` method.
     It takes in an optional suffix and keyword arguments also.
     An empty string as suffix will override the existing column.
 
-    This method mutates the original DataFrame.
+    This method does not mutate the original DataFrame.
 
-    Functional usage syntax:
+    Example:
 
-    ```python
-    df = factorize_columns(
-        df,
-        column_names="my_categorical_column",
-        suffix="_enc"
-    )  # one way
-    ```
-
-    Method chaining syntax:
-
-    ```python
-    import pandas as pd
-    import janitor
-    categorical_cols = ['col1', 'col2', 'col4']
-    df = (
-        pd.DataFrame(...)
-        .factorize_columns(
-            column_names=categorical_cols,
-            suffix="_enc"
-        )
-    )
-    ```
+        >>> import pandas as pd
+        >>> import janitor
+        >>> df = pd.DataFrame({
+        ...     "foo": ["b", "b", "a", "c", "b"],
+        ...     "bar": range(4, 9),
+        ... })
+        >>> df
+          foo  bar
+        0   b    4
+        1   b    5
+        2   a    6
+        3   c    7
+        4   b    8
+        >>> df.factorize_columns(column_names="foo")
+          foo  bar  foo_enc
+        0   b    4        0
+        1   b    5        0
+        2   a    6        1
+        3   c    7        2
+        4   b    8        0
 
     :param df: The pandas DataFrame object.
-    :param column_names: A column name or an iterable (list
-        or tuple) of column names.
-    :param suffix: Suffix to be used for the new column. Default value is _enc.
-        An empty string suffix means, it will override the existing column
+    :param column_names: A column name or an iterable (list or tuple) of
+        column names.
+    :param suffix: Suffix to be used for the new column.
+        An empty string suffix means, it will override the existing column.
     :param **kwargs: Keyword arguments. It takes any of the keyword arguments,
-        which the pandas factorize method takes like sort,na_sentinel,size_hint
+        which the pandas factorize method takes like `sort`, `na_sentinel`,
+        `size_hint`.
 
     :returns: A pandas DataFrame.
     """
-    df = _factorize(df, column_names, suffix, **kwargs)
+    df = _factorize(df.copy(), column_names, suffix, **kwargs)
     return df
diff --git a/janitor/functions/label_encode.py b/janitor/functions/label_encode.py
index a192d37..a8b3995 100644
--- a/janitor/functions/label_encode.py
+++ b/janitor/functions/label_encode.py
@@ -1,3 +1,4 @@
+"""Implementation of `label_encode` function"""
 from typing import Hashable, Iterable, Union
 import warnings
 import pandas_flavor as pf
@@ -10,43 +11,62 @@ from janitor.functions.utils import _factorize
 @pf.register_dataframe_method
 @deprecated_alias(columns="column_names")
 def label_encode(
-    df: pd.DataFrame, column_names: Union[str, Iterable[str], Hashable]
+    df: pd.DataFrame,
+    column_names: Union[str, Iterable[str], Hashable],
 ) -> pd.DataFrame:
     """
     Convert labels into numerical data.
 
     This method will create a new column with the string `_enc` appended
-    after the original column's name. Consider this to be syntactic sugar.
+    after the original column's name.
+    Consider this to be syntactic sugar.
+    This function uses the `factorize` pandas function under the hood.
 
-    This method behaves differently from `encode_categorical`. This method
-    creates a new column of numeric data. `encode_categorical` replaces the
-    dtype of the original column with a *categorical* dtype.
+    This method behaves differently from
+    [`encode_categorical`][janitor.functions.encode_categorical.encode_categorical].
+    This method creates a new column of numeric data.
+    [`encode_categorical`][janitor.functions.encode_categorical.encode_categorical]
+    replaces the dtype of the original column with a *categorical* dtype.
 
     This method mutates the original DataFrame.
 
-    Functional usage syntax:
+    Example:
 
-    ```python
-    df = label_encode(df, column_names="my_categorical_column")  # one way
-    ```
+        >>> import pandas as pd
+        >>> import janitor
+        >>> df = pd.DataFrame({
+        ...     "foo": ["b", "b", "a", "c", "b"],
+        ...     "bar": range(4, 9),
+        ... })
+        >>> df
+          foo  bar
+        0   b    4
+        1   b    5
+        2   a    6
+        3   c    7
+        4   b    8
+        >>> df.label_encode(column_names="foo")
+          foo  bar  foo_enc
+        0   b    4        0
+        1   b    5        0
+        2   a    6        1
+        3   c    7        2
+        4   b    8        0
 
-    Method chaining syntax:
+    !!!note
 
-    ```python
-    import pandas as pd
-    import janitor
-    categorical_cols = ['col1', 'col2', 'col4']
-    df = pd.DataFrame(...).label_encode(column_names=categorical_cols)
-    ```
+        This function will be deprecated in a 1.x release.
+        Please use [`factorize_columns`][janitor.functions.factorize_columns.factorize_columns]
+        instead.
 
     :param df: The pandas DataFrame object.
     :param column_names: A column name or an iterable (list
         or tuple) of column names.
     :returns: A pandas DataFrame.
-    """
+    """  # noqa: E501
     warnings.warn(
-        "label_encode will be deprecated in a 1.x release. \
-        Please use factorize_columns instead"
+        "`label_encode` will be deprecated in a 1.x release. "
+        "Please use `factorize_columns` instead."
     )
     df = _factorize(df, column_names, "_enc")
     return df
diff --git a/janitor/functions/utils.py b/janitor/functions/utils.py
index 08dbf4d..3e033b8 100644
--- a/janitor/functions/utils.py
+++ b/janitor/functions/utils.py
@@ -12,7 +12,6 @@ from janitor.utils import check, _expand_grid
 from pandas.api.types import (
     union_categoricals,
     is_scalar,
-    is_extension_array_dtype,
     is_list_like,
 )
 import numpy as np
@@ -142,12 +141,10 @@ def patterns(regex_pattern: Union[str, Pattern]) -> Pattern:
 def _computations_expand_grid(others: dict) -> pd.DataFrame:
     """
     Creates a cartesian product of all the inputs in `others`.
-    Combines NumPy's `mgrid`, with the `take` method in NumPy/pandas
-    to expand each input to the length of the cumulative product of
-    all inputs in `others`.
+    Uses numpy's `mgrid` to generate indices, which is used to
+    `explode` all the inputs in `others`.
 
     There is a performance penalty for small entries
-    (lenght less than 10)
     in using this method, instead of `itertools.product`;
     however, there are significant performance benefits
     as the size of the data increases.
@@ -167,13 +164,11 @@ def _computations_expand_grid(others: dict) -> pd.DataFrame:
 
     for key, value in others.items():
         if is_scalar(value):
-            value = pd.DataFrame([value])
-        elif (not isinstance(value, pd.Series)) and is_extension_array_dtype(
-            value
-        ):
-            value = pd.DataFrame(value)
+            value = np.asarray([value])
         elif is_list_like(value) and (not hasattr(value, "shape")):
             value = np.asarray([*value])
+        if not value.size:
+            raise ValueError(f"Kindly provide a non-empty array for {key}.")
 
         grid[key] = value
 
@@ -183,20 +178,16 @@ def _computations_expand_grid(others: dict) -> pd.DataFrame:
     # to generate cartesian indices
     # which is then paired with grid.items()
     # to blow up each individual value
-    # before finally recombining, via pd.concat,
-    # to create a dataframe.
-    grid_index = [slice(len(value)) for _, value in grid.items()]
-    grid_index = np.mgrid[grid_index]
-    grid_index = map(np.ravel, grid_index)
-    grid = zip(grid.items(), grid_index)
+    # before creating the final DataFrame.
+    grid = grid.items()
+    grid_index = [slice(len(value)) for _, value in grid]
+    grid_index = map(np.ravel, np.mgrid[grid_index])
+    grid = zip(grid, grid_index)
     grid = ((*left, right) for left, right in grid)
-    grid = {
-        key: _expand_grid(value, grid_index) for key, value, grid_index in grid
-    }
-
-    # creates a MultiIndex with the keys
-    # since grid is a dictionary
-    return pd.concat(grid, axis="columns", sort=False, copy=False)
+    contents = {}
+    for key, value, grid_index in grid:
+        contents = {**contents, **_expand_grid(value, grid_index, key)}
+    return pd.DataFrame(contents, copy=False)
 
 
 @dispatch(pd.DataFrame, (list, tuple), str)
diff --git a/janitor/utils.py b/janitor/utils.py
index 2750d5c..2f93a4f 100644
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -14,6 +14,7 @@ from typing import (
 
 import numpy as np
 import pandas as pd
+from pandas.core.construction import extract_array
 
 
 def check(varname: str, value, expected_types: list):
@@ -42,99 +43,125 @@ def check(varname: str, value, expected_types: list):
             break
 
     if not is_expected_type:
-        raise TypeError(
-            "{varname} should be one of {expected_types}".format(
-                varname=varname, expected_types=expected_types
-            )
-        )
+        raise TypeError(f"{varname} should be one of {expected_types}.")
 
 
 @functools.singledispatch
-def _expand_grid(value, grid_index):
+def _expand_grid(value, grid_index, key):
     """
     Base function for dispatch of `_expand_grid`.
     """
 
     raise TypeError(
-        f"""
-        {type(value).__name__} data type
-        is not supported in `expand_grid`.
-        """
+        f"{type(value).__name__} data type "
+        "is not supported in `expand_grid`."
     )
 
 
 @_expand_grid.register(np.ndarray)
-def _sub_expand_grid(value, grid_index):  # noqa: F811
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
     """
     Expands the numpy array based on `grid_index`.
 
-    Returns Series if 1-D array,
-    or DataFrame if 2-D array.
+    Returns a dictionary.
     """
 
-    if not (value.size > 0):
-        raise ValueError("""array cannot be empty.""")
-
     if value.ndim > 2:
         raise ValueError(
-            """
-            expand_grid works only
-            on 1D and 2D arrays.
-            """
+            "expand_grid works only on 1D and 2D arrays. "
+            f"The provided array for {key} however "
+            f"has a dimension of {value.ndim}."
         )
 
     value = value[grid_index]
 
-    return pd.DataFrame(value)
+    if value.ndim == 1:
+        return {(key, 0): value}
+
+    return {(key, num): arr for num, arr in enumerate(value.T)}
+
+
+@_expand_grid.register(pd.arrays.PandasArray)
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
+    """
+    Expands the pandas array based on `grid_index`.
+
+    Returns a dictionary.
+    """
+
+    value = value[grid_index]
+
+    return {(key, 0): value}
 
 
 @_expand_grid.register(pd.Series)
-def _sub_expand_grid(value, grid_index):  # noqa: F811
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
     """
     Expands the Series based on `grid_index`.
 
-    Returns Series.
+    Returns a dictionary.
     """
-    if value.empty:
-        raise ValueError("""Series cannot be empty.""")
 
-    value = value.iloc[grid_index]
-    value.index = pd.RangeIndex(start=0, stop=len(value))
+    name = value.name
+    if not name:
+        name = 0
+    value = extract_array(value, extract_numpy=True)[grid_index]
 
-    return value.to_frame()
+    return {(key, name): value}
 
 
 @_expand_grid.register(pd.DataFrame)
-def _sub_expand_grid(value, grid_index):  # noqa: F811
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
     """
     Expands the DataFrame based on `grid_index`.
 
-    Returns a DataFrame.
+    Returns a dictionary.
     """
-    if value.empty:
-        raise ValueError("""DataFrame cannot be empty.""")
 
-    value = value.iloc[grid_index]
-    value.index = pd.RangeIndex(start=0, stop=len(value))
+    # use set_axis here, to prevent the column change from
+    # transmitting back to the original dataframe
     if isinstance(value.columns, pd.MultiIndex):
-        value.columns = ["_".join(map(str, ent)) for ent in value]
+        columns = ["_".join(map(str, ent)) for ent in value]
+        value = value.set_axis(columns, axis="columns")
 
-    return value
+    return {
+        (key, name): extract_array(val, extract_numpy=True)[grid_index]
+        for name, val in value.items()
+    }
 
 
-@_expand_grid.register(pd.Index)
-def _sub_expand_grid(value, grid_index):  # noqa: F811
+@_expand_grid.register(pd.MultiIndex)
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
     """
-    Expands the Index based on `grid_index`.
+    Expands the MultiIndex based on `grid_index`.
 
-    Returns a DataFrame (if MultiIndex), or a Series.
+    Returns a dictionary.
     """
-    if value.empty:
-        raise ValueError("""Index cannot be empty.""")
 
-    value = value[grid_index]
+    contents = {}
+    num = 0
+    for n in range(value.nlevels):
+        arr = value.get_level_values(n)
+        name = arr.name
+        arr = extract_array(arr, extract_numpy=True)[grid_index]
+        if not name:
+            name = num
+            num += 1
+        contents[(key, name)] = arr
+    return contents
+
 
-    return value.to_frame(index=False)
+@_expand_grid.register(pd.Index)
+def _sub_expand_grid(value, grid_index, key):  # noqa: F811
+    """
+    Expands the Index based on `grid_index`.
+
+    Returns a dictionary.
+    """
+    name = value.name
+    if not name:
+        name = 0
+    return {(key, name): extract_array(value, extract_numpy=True)[grid_index]}
 
 
 def import_message(


Test patch:
diff --git a/.requirements/testing.txt b/.requirements/testing.txt
index 9ecf071..0f098c5 100644
--- a/.requirements/testing.txt
+++ b/.requirements/testing.txt
@@ -1,29 +1,70 @@
 #
-# This file is autogenerated by pip-compile
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile ./.requirements/testing.in
 #
-attrs==20.2.0              # via hypothesis, interrogate, pandas-vet, pytest
-click==7.1.2               # via interrogate
-colorama==0.4.4            # via interrogate
-coverage==5.3              # via pytest-cov
-flake8==3.8.4              # via pandas-vet
-hypothesis==5.37.3         # via -r ./.requirements/testing.in
-iniconfig==1.1.1           # via pytest
-interrogate==1.3.1         # via -r ./.requirements/testing.in
-mccabe==0.6.1              # via flake8
-packaging==20.4            # via pytest
-pandas-vet==0.2.2          # via -r ./.requirements/testing.in
-pluggy==0.13.1             # via pytest
-py==1.10.0                 # via interrogate, pytest
-pycodestyle==2.6.0         # via flake8
-pyflakes==2.2.0            # via flake8
-pyparsing==2.4.7           # via packaging
-pytest-azurepipelines==0.8.0  # via -r ./.requirements/testing.in
-pytest-cov==2.10.1         # via -r ./.requirements/testing.in
-pytest==6.1.1              # via -r ./.requirements/testing.in, pytest-azurepipelines, pytest-cov
-six==1.15.0                # via packaging
-sortedcontainers==2.2.2    # via hypothesis
-tabulate==0.8.7            # via interrogate
-toml==0.10.1               # via interrogate, pytest
+attrs==20.2.0
+    # via
+    #   hypothesis
+    #   interrogate
+    #   pandas-vet
+    #   pytest
+click==7.1.2
+    # via interrogate
+colorama==0.4.4
+    # via interrogate
+coverage==5.3
+    # via pytest-cov
+execnet==1.9.0
+    # via pytest-xdist
+flake8==3.8.4
+    # via pandas-vet
+hypothesis==5.37.3
+    # via -r ./.requirements/testing.in
+iniconfig==1.1.1
+    # via pytest
+interrogate==1.3.1
+    # via -r ./.requirements/testing.in
+mccabe==0.6.1
+    # via flake8
+packaging==20.4
+    # via pytest
+pandas-vet==0.2.2
+    # via -r ./.requirements/testing.in
+pluggy==0.13.1
+    # via pytest
+py==1.10.0
+    # via
+    #   -r ./.requirements/testing.in
+    #   interrogate
+    #   pytest
+    #   pytest-forked
+pycodestyle==2.6.0
+    # via flake8
+pyflakes==2.2.0
+    # via flake8
+pyparsing==2.4.7
+    # via packaging
+pytest==7.0.1
+    # via
+    #   -r ./.requirements/testing.in
+    #   pytest-cov
+    #   pytest-forked
+    #   pytest-xdist
+pytest-cov==2.10.1
+    # via -r ./.requirements/testing.in
+pytest-forked==1.4.0
+    # via pytest-xdist
+pytest-xdist==2.5.0
+    # via -r ./.requirements/testing.in
+six==1.15.0
+    # via packaging
+sortedcontainers==2.2.2
+    # via hypothesis
+tabulate==0.8.7
+    # via interrogate
+toml==0.10.1
+    # via interrogate
+tomli==2.0.1
+    # via pytest
diff --git a/tests/functions/test_complete.py b/tests/functions/test_complete.py
index 77e7d99..bffbfb2 100644
--- a/tests/functions/test_complete.py
+++ b/tests/functions/test_complete.py
@@ -321,24 +321,24 @@ def test_tuple_column():
     """Test `complete` output if a tuple is provided."""
     df = pd.DataFrame(
         {
-            "group": [1, 2, 1],
-            "item_id": [1, 2, 2],
-            "item_name": ["a", "b", "b"],
-            "value1": [1, 2, 3],
-            "value2": [4, 5, 6],
+            "group": [2, 1, 1],
+            "item_id": [2, 1, 2],
+            "item_name": ["b", "a", "b"],
+            "value1": [2, 1, 3],
+            "value2": [5, 4, 6],
         }
     )
 
     result = df.complete("group", ("item_id", "item_name"), sort=True)
 
-    expected = pd.DataFrame(
-        {
-            "group": [1, 1, 2, 2],
-            "item_id": [1, 2, 1, 2],
-            "item_name": ["a", "b", "a", "b"],
-            "value1": [1.0, 3.0, np.nan, 2.0],
-            "value2": [4.0, 6.0, np.nan, 5.0],
-        }
+    columns = ["group", "item_id", "item_name"]
+    expected = (
+        df.set_index(columns)
+        .unstack("group")
+        .stack(dropna=False)
+        .reset_index()
+        .reindex(columns=df.columns)
+        .sort_values(columns, ignore_index=True)
     )
     assert_frame_equal(result, expected)
 
@@ -377,23 +377,21 @@ def test_complete_multiple_groupings():
 
 def test_fill_value_scalar(taxonomy_df):
     """Test output if the fill_value is a scalar."""
-    result = taxonomy_df.complete("Year", "Taxon", fill_value=0, sort=True)
-    expected = pd.DataFrame(
-        [
-            {"Year": 1999, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2000, "Taxon": "Agarum", "Abundance": 0},
-            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2004, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 2},
-        ]
+    result = taxonomy_df.complete("Year", "Taxon", fill_value=0, sort=False)
+    expected = (
+        taxonomy_df.encode_categorical(Taxon="appearance")
+        .set_index(["Year", "Taxon"])
+        .unstack(fill_value=0)
+        .stack(dropna=False)
+        .reset_index()
+        .astype({"Taxon": "object"})
     )
 
     assert_frame_equal(result, expected)
 
 
 #  http://imachordata.com/2016/02/05/you-complete-me/
-def test_dict_tuple(taxonomy_df):
+def test_dict_tuple_callable(taxonomy_df):
     """
     Test output if a dictionary and a tuple/list
     are included in the `columns` parameter.
@@ -405,39 +403,45 @@ def test_dict_tuple(taxonomy_df):
         sort=True,
     )
 
-    expected = pd.DataFrame(
-        [
-            {"Year": 1999, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 1999, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 1999, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2000, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 2000, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2000, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2001, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 2001, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2001, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 2001, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2001, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2002, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 2002, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2002, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 2002, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2002, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2003, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 2003, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2003, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 2003, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2003, "Taxon": "Saccharina", "Abundance": 5},
-            {"Year": 2004, "Taxon": "Agarum", "Abundance": 1},
-            {"Year": 2004, "Taxon": "Agarum", "Abundance": 8},
-            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 2},
-            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 4},
-            {"Year": 2004, "Taxon": "Saccharina", "Abundance": 5},
-        ]
+    expected = (
+        taxonomy_df.set_index(["Year", "Taxon", "Abundance"])
+        .assign(dummy=1)
+        .unstack("Year")
+        .droplevel(0, 1)
+        .reindex(columns=range(1999, 2005))
+        .stack(dropna=False)
+        .reset_index()
+        .iloc[:, :-1]
+        .reindex(columns=["Year", "Taxon", "Abundance"])
+        .sort_values(["Year", "Taxon", "Abundance"], ignore_index=True)
+    )
+
+    assert_frame_equal(result, expected)
+
+
+def test_dict_tuple(taxonomy_df):
+    """
+    Test output if a dictionary and a tuple/list
+    are included in the `columns` parameter.
+    """
+
+    result = taxonomy_df.complete(
+        {"Year": [2000, 1999, 2001, 2002, 2003, 2004]},
+        ("Taxon", "Abundance"),
+        sort=True,
+    )
+
+    expected = (
+        taxonomy_df.set_index(["Year", "Taxon", "Abundance"])
+        .assign(dummy=1)
+        .unstack("Year")
+        .droplevel(0, 1)
+        .reindex(columns=range(1999, 2005))
+        .stack(dropna=False)
+        .reset_index()
+        .iloc[:, :-1]
+        .reindex(columns=["Year", "Taxon", "Abundance"])
+        .sort_values(["Year", "Taxon", "Abundance"], ignore_index=True)
     )
 
     assert_frame_equal(result, expected)
@@ -457,29 +461,14 @@ def test_complete_groupby():
         {"year": lambda x: range(x.min(), x.max() + 1)}, by="state", sort=True
     )
 
-    expected = pd.DataFrame(
-        [
-            {"state": "CA", "year": 2010, "value": 1.0},
-            {"state": "CA", "year": 2011, "value": np.nan},
-            {"state": "CA", "year": 2012, "value": np.nan},
-            {"state": "CA", "year": 2013, "value": 3.0},
-            {"state": "HI", "year": 2010, "value": 1.0},
-            {"state": "HI", "year": 2011, "value": np.nan},
-            {"state": "HI", "year": 2012, "value": 2.0},
-            {"state": "HI", "year": 2013, "value": np.nan},
-            {"state": "HI", "year": 2014, "value": np.nan},
-            {"state": "HI", "year": 2015, "value": np.nan},
-            {"state": "HI", "year": 2016, "value": 3.0},
-            {"state": "NY", "year": 2009, "value": 2.0},
-            {"state": "NY", "year": 2010, "value": np.nan},
-            {"state": "NY", "year": 2011, "value": np.nan},
-            {"state": "NY", "year": 2012, "value": np.nan},
-            {"state": "NY", "year": 2013, "value": 5.0},
-        ]
+    expected = (
+        df.set_index("year")
+        .groupby("state")
+        .apply(lambda x: x.reindex(range(x.index.min(), x.index.max() + 1)))
+        .drop(columns="state")
+        .reset_index()
     )
 
-    expected = expected.reindex(columns=df.columns)
-
     assert_frame_equal(result, expected)
 
 
@@ -490,69 +479,48 @@ def test_explicit_scalar(fill_df):
         ("item_id", "item_name"),
         fill_value=0,
         explicit=False,
-        sort=True,
+    ).astype({"value2": int})
+    columns = ["group", "item_id", "item_name"]
+    expected = (
+        fill_df.set_index(columns)
+        .unstack("group", fill_value=0)
+        .stack(dropna=False)
+        .reset_index()
+        .reindex(columns=fill_df.columns)
+        .sort_values(columns, ignore_index=True)
     )
-    expected = pd.DataFrame(
-        [
-            {
-                "group": 1,
-                "item_id": 1,
-                "item_name": "a",
-                "value1": 1.0,
-                "value2": 4,
-            },
-            {
-                "group": 1,
-                "item_id": 2,
-                "item_name": "a",
-                "value1": 0.0,
-                "value2": 0,
-            },
-            {
-                "group": 1,
-                "item_id": 2,
-                "item_name": "b",
-                "value1": 3.0,
-                "value2": 6,
-            },
-            {
-                "group": 1,
-                "item_id": 3,
-                "item_name": "b",
-                "value1": 0.0,
-                "value2": 0,
-            },
-            {
-                "group": 2,
-                "item_id": 1,
-                "item_name": "a",
-                "value1": 0.0,
-                "value2": 0,
-            },
-            {
-                "group": 2,
-                "item_id": 2,
-                "item_name": "a",
-                "value1": np.nan,
-                "value2": 5,
-            },
-            {
-                "group": 2,
-                "item_id": 2,
-                "item_name": "b",
-                "value1": 0.0,
-                "value2": 0,
-            },
+    assert_frame_equal(result, expected)
+
+
+def test_explicit_scalar_cat(fill_df):
+    """
+    Test output if fill_value is a scalar, explicit is False,
+    and one of the columns is of category dtype.
+    """
+    fill_df = fill_df.astype({"value1": "category"})
+    result = fill_df.complete(
+        "group",
+        ("item_id", "item_name"),
+        fill_value=0,
+        explicit=False,
+    ).astype({"value2": int})
+    columns = ["group", "item_id", "item_name"]
+    fill_df["value1"] = fill_df["value1"].cat.add_categories([0])
+    expected = (
+        fill_df.set_index(columns)
+        .unstack("group", fill_value=0)
+        .stack(dropna=False)
+        .reset_index()
+        .reindex(columns=fill_df.columns)
+        .sort_values(columns, ignore_index=True)
+        .astype(
             {
-                "group": 2,
-                "item_id": 3,
-                "item_name": "b",
-                "value1": 4.0,
-                "value2": 7,
-            },
-        ]
+                "value1": pd.CategoricalDtype(
+                    categories=[1.0, 3.0, 4.0, 0.0], ordered=False
+                )
+            }
+        )
     )
-
     assert_frame_equal(result, expected)
 
 
@@ -565,7 +533,7 @@ def test_explicit_dict(fill_df):
         fill_value={"value1": 0, "value2": 99},
         explicit=False,
         sort=True,
-    )
+    ).astype({"value2": int})
     expected = pd.DataFrame(
         [
             {
diff --git a/tests/functions/test_expand_grid.py b/tests/functions/test_expand_grid.py
index bb28444..c54c73d 100644
--- a/tests/functions/test_expand_grid.py
+++ b/tests/functions/test_expand_grid.py
@@ -18,8 +18,8 @@ def test_others_not_dict(df):
 
 
 @given(df=df_strategy())
-def test_others_None(df):
-    """Return DataFrame if no `others`."""
+def test_others_none(df):
+    """Return DataFrame if no `others`, and df exists."""
     assert_frame_equal(df.expand_grid("df"), df)
 
 
@@ -30,14 +30,14 @@ def test_others_empty():
 
 @given(df=df_strategy())
 def test_df_key(df):
-    """Raise error if dataframe key is not supplied."""
+    """Raise error if df exists and df_key is not supplied."""
     with pytest.raises(KeyError):
         expand_grid(df, others={"y": [5, 4, 3, 2, 1]})
 
 
 @given(df=df_strategy())
 def test_df_key_hashable(df):
-    """Raise error if dataframe key is not Hashable."""
+    """Raise error if df exists and df_key is not Hashable."""
     with pytest.raises(TypeError):
         expand_grid(df, df_key=["a"], others={"y": [5, 4, 3, 2, 1]})
 
@@ -54,19 +54,19 @@ def test_numpy_gt_2d():
         expand_grid(others={"x": np.array([[[2, 3]]])})
 
 
-def test_Series_empty():
+def test_series_empty():
     """Raise ValueError if Series is empty."""
     with pytest.raises(ValueError):
         expand_grid(others={"x": pd.Series([], dtype=int)})
 
 
-def test_DataFrame_empty():
-    """Raise ValueError if Series is empty."""
+def test_dataframe_empty():
+    """Raise ValueError if DataFrame is empty."""
     with pytest.raises(ValueError):
         expand_grid(others={"x": pd.DataFrame([])})
 
 
-def test_Index_empty():
+def test_index_empty():
     """Raise ValueError if Index is empty."""
     with pytest.raises(ValueError):
         expand_grid(others={"x": pd.Index([], dtype=int)})
@@ -74,8 +74,8 @@ def test_Index_empty():
 
 @settings(deadline=None)
 @given(df=df_strategy())
-def test_Series(df):
-    """Test expand_grid output"""
+def test_series(df):
+    """Test expand_grid output for Series input."""
     A = df["a"]
     B = df["cities"]
     others = {"A": A, "B": B}
@@ -92,8 +92,8 @@ def test_Series(df):
 
 @settings(deadline=None)
 @given(df=df_strategy())
-def test_Series_DataFrame(df):
-    """Test expand_grid output"""
+def test_series_dataframe(df):
+    """Test expand_grid output for Series and DataFrame inputs."""
     A = df["a"]
     B = df.iloc[:, [1, 2]]
     others = {"A": A, "B": B}
@@ -109,8 +109,11 @@ def test_Series_DataFrame(df):
 
 @settings(deadline=None)
 @given(df=df_strategy())
-def test_Series_MultiIndex_DataFrame(df):
-    """Test expand_grid output"""
+def test_series_multiindex_dataframe(df):
+    """
+    Test expand_grid output
+    if the DataFrame's columns is a MultiIndex.
+    """
     A = df["a"]
     B = df.iloc[:, [1, 2]]
     B.columns = pd.MultiIndex.from_arrays([["C", "D"], B.columns])
@@ -129,7 +132,7 @@ def test_Series_MultiIndex_DataFrame(df):
 @settings(deadline=None)
 @given(df=df_strategy())
 def test_numpy_1d(df):
-    """Test expand_grid output"""
+    """Test expand_grid output for a 1D numpy array."""
     A = df["a"].to_numpy()
     B = df["cities"]
     others = {"A": A, "B": B}
@@ -147,7 +150,7 @@ def test_numpy_1d(df):
 @settings(deadline=None)
 @given(df=categoricaldf_strategy())
 def test_numpy_2d(df):
-    """Test expand_grid output"""
+    """Test expand_grid output for a 2D numpy array"""
     A = df["names"]
     base = df.loc[:, ["numbers"]].assign(num=df.numbers * 4)
     B = base.to_numpy(dtype=int)
@@ -165,8 +168,8 @@ def test_numpy_2d(df):
 
 @settings(deadline=None)
 @given(df=df_strategy())
-def test_Index(df):
-    """Test expand_grid output"""
+def test_index(df):
+    """Test expand_grid output for a pandas Index that has a name."""
     A = pd.Index(df["a"])
     B = df["cities"]
     others = {"A": A, "B": B}
@@ -181,10 +184,26 @@ def test_Index(df):
     assert_frame_equal(result, expected)
 
 
+@settings(deadline=None)
+@given(df=df_strategy())
+def test_index_name_none(df):
+    """Test expand_grid output for a pandas Index without a name."""
+    A = pd.Index(df["a"].array, name=None)
+    B = df["cities"]
+    others = {"A": A, "B": B}
+    result = expand_grid(others=others)
+    A = df.loc[:, ["a"]]
+    B = df.loc[:, ["cities"]]
+    expected = A.assign(key=1).merge(B.assign(key=1), on="key")
+    expected = expected.drop(columns="key")
+    expected.columns = pd.MultiIndex.from_arrays([["A", "B"], [0, "cities"]])
+    assert_frame_equal(result, expected)
+
+
 @settings(deadline=None)
 @given(df=categoricaldf_strategy())
-def test_MultiIndex(df):
-    """Test expand_grid output"""
+def test_multiindex(df):
+    """Test expand_grid output for a pandas MultiIndex with a name."""
     A = df["names"]
     base = df.loc[:, ["numbers"]].assign(num=df.numbers * 4)
     B = pd.MultiIndex.from_frame(base)
@@ -200,10 +219,29 @@ def test_MultiIndex(df):
     assert_frame_equal(result, expected)
 
 
+@settings(deadline=None)
+@given(df=categoricaldf_strategy())
+def test_multiindex_names_none(df):
+    """Test expand_grid output for a pandas MultiIndex without a name."""
+    A = df["names"]
+    base = df.loc[:, ["numbers"]].assign(num=df.numbers * 4)
+    B = pd.MultiIndex.from_frame(base, names=[None, None])
+    others = {"A": A, "B": B}
+    result = expand_grid(others=others)
+    A = df.loc[:, ["names"]]
+    B = base.copy()
+    expected = A.assign(key=1).merge(B.assign(key=1), on="key")
+    expected = expected.drop(columns="key")
+    expected.columns = pd.MultiIndex.from_arrays(
+        [["A", "B", "B"], ["names", 0, 1]]
+    )
+    assert_frame_equal(result, expected)
+
+
 @settings(deadline=None)
 @given(df=df_strategy())
-def test_Pandas_extension_array(df):
-    """Test expand_grid output"""
+def test_pandas_extension_array(df):
+    """Test expand_grid output for a pandas array."""
     A = df["a"]
     B = df["cities"].astype("string").array
     others = {"A": A, "B": B}
@@ -221,7 +259,7 @@ def test_Pandas_extension_array(df):
 @settings(deadline=None)
 @given(df=df_strategy())
 def test_sequence(df):
-    """Test expand_grid output"""
+    """Test expand_grid output for list."""
     A = df["a"].to_list()
     B = df["cities"]
     others = {"A": A, "B": B}
@@ -239,7 +277,7 @@ def test_sequence(df):
 @settings(deadline=None)
 @given(df=df_strategy())
 def test_scalar(df):
-    """Test expand_grid output"""
+    """Test expand_grid output for a scalar value."""
     A = df["a"]
     B = 2
     others = {"A": A, "B": B}
@@ -252,3 +290,37 @@ def test_scalar(df):
         [["A", "B"], expected.columns]
     )
     assert_frame_equal(result, expected)
+
+
+@settings(deadline=None)
+@given(df=df_strategy())
+def test_chain_df(df):
+    """Test expand_grid in a method-chain operation."""
+    A = df["a"]
+    B = df[["cities"]]
+    others = {"A": A}
+    result = B.expand_grid(df_key="city", others=others)
+    A = df.loc[:, ["a"]]
+    expected = B.assign(key=1).merge(A.assign(key=1), on="key")
+    expected = expected.drop(columns="key")
+    expected.columns = pd.MultiIndex.from_arrays(
+        [["city", "A"], expected.columns]
+    )
+    assert_frame_equal(result, expected)
+
+
+@settings(deadline=None)
+@given(df=df_strategy())
+def test_series_name(df):
+    """Test expand_grid where the Series has no name."""
+    A = df["a"].rename(None)
+    B = df[["cities"]]
+    others = {"A": A}
+    result = B.expand_grid(df_key="city", others=others)
+    A = df.loc[:, ["a"]]
+    expected = B.assign(key=1).merge(A.assign(key=1), on="key")
+    expected = expected.drop(columns="key")
+    expected.columns = pd.MultiIndex.from_arrays(
+        [["city", "A"], ["cities", 0]]
+    )
+    assert_frame_equal(result, expected)