diff --git a/datacompy/_version.py b/datacompy/_version.py
index d11c499..fe34cde 100644
--- a/datacompy/_version.py
+++ b/datacompy/_version.py
@@ -14,4 +14,4 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-__version__ = "0.5.1"
+__version__ = "0.5.2"
diff --git a/datacompy/core.py b/datacompy/core.py
index e39fa5e..6025f8a 100644
--- a/datacompy/core.py
+++ b/datacompy/core.py
@@ -24,6 +24,7 @@ two dataframes.
 
 import os
 import logging
+from datetime import datetime
 import pandas as pd
 import numpy as np
 
@@ -219,16 +220,8 @@ class Compare(object):
 
             # Create order column for uniqueness of match
             order_column = temp_column_name(self.df1, self.df2)
-            self.df1[order_column] = (
-                self.df1.sort_values(by=list(self.df1.columns))
-                .groupby(temp_join_columns)
-                .cumcount()
-            )
-            self.df2[order_column] = (
-                self.df2.sort_values(by=list(self.df2.columns))
-                .groupby(temp_join_columns)
-                .cumcount()
-            )
+            self.df1[order_column] = generate_id_within_group(self.df1, temp_join_columns)
+            self.df2[order_column] = generate_id_within_group(self.df2, temp_join_columns)
             temp_join_columns.append(order_column)
 
             params = {"on": temp_join_columns}
@@ -761,3 +754,34 @@ def calculate_max_diff(col_1, col_2):
         return (col_1.astype(float) - col_2.astype(float)).abs().max()
     except:
         return 0
+
+
+def generate_id_within_group(dataframe, join_columns):
+    """Generate an ID column that can be used to deduplicate identical rows.  The series generated
+    is the order within a unique group, and it handles nulls.
+
+    Parameters
+    ----------
+    dataframe : Pandas.DataFrame
+        The dataframe to operate on
+    join_columns : list
+        List of strings which are the join columns
+
+    Returns
+    -------
+    Pandas.Series
+        The ID column that's unique in each group.
+    """
+    default_value = "DATACOMPY_NULL"
+    if dataframe[join_columns].isnull().any().any():
+        if (dataframe[join_columns] == default_value).any().any():
+            raise ValueError("{} was found in your join columns".format(default_value))
+        return (
+            dataframe[join_columns]
+            .astype(str)
+            .fillna(default_value)
+            .groupby(join_columns)
+            .cumcount()
+        )
+    else:
+        return dataframe[join_columns].groupby(join_columns).cumcount()
diff --git a/sphinx/pandas_usage.rst b/sphinx/pandas_usage.rst
index 7f9620c..0a5ae43 100644
--- a/sphinx/pandas_usage.rst
+++ b/sphinx/pandas_usage.rst
@@ -36,7 +36,7 @@ acct_id     dollar_amt name                 float_fld
 Set up like:
 
 .. code-block:: python
-    
+
     from io import StringIO
     import pandas as pd
     import datacompy
@@ -82,7 +82,7 @@ join column(s) or by index.
 
     compare = datacompy.Compare(df1, df2, join_columns=['acct_id', 'name'])
 
-    # OR 
+    # OR
 
     compare = datacompy.Compare(df1, df2, on_index=True)
 
@@ -196,6 +196,66 @@ There are a few convenience methods available after the comparison has been run:
     print(compare.df2_unq_columns())
     # set()
 
+Duplicate rows
+--------------
+
+Datacompy will try to handle rows that are duplicate in the join columns.  It does this behind the
+scenes by generating a unique ID within each unique group of the join columns.  For example, if you
+have two dataframes you're trying to join on acct_id:
+
+=========== ================
+acct_id     name
+=========== ================
+1           George Maharis
+1           Michael Bluth
+2           George Bluth
+=========== ================
+
+=========== ================
+acct_id     name
+=========== ================
+1           George Maharis
+1           Michael Bluth
+1           Tony Wonder
+2           George Bluth
+=========== ================
+
+Datacompy will generate a unique temporary ID for joining:
+
+=========== ================ ========
+acct_id     name             temp_id
+=========== ================ ========
+1           George Maharis   0
+1           Michael Bluth    1
+2           George Bluth     0
+=========== ================ ========
+
+=========== ================ ========
+acct_id     name             temp_id
+=========== ================ ========
+1           George Maharis   0
+1           Michael Bluth    1
+1           Tony Wonder      2
+2           George Bluth     0
+=========== ================ ========
+
+And then merge the two dataframes on a combination of the join_columns you specified and the temporary
+ID, before dropping the temp_id again.  So the first two rows in the first dataframe will match the
+first two rows in the second dataframe, and the third row in the second dataframe will be recognized
+as uniquely in the second.
+
+Caveats
++++++++
+
+- Duplicate matching is resilient to nulls in your join columns - it will convert the join
+  columns to strings and fill null values with ``'DATACOMPY_NULL'`` before generating the temporary
+  ID.  If you already have ``'DATACOMPY_NULL'`` as a value in your join columns, the merge step will
+  fail with a ``ValueError``.  You can also fill null values with a value of your choice before
+  initializing the ``Compare`` class, based on what you know about the data.
+- The duplicate matching is somewhat naïve when it comes to picking which rows to match when there
+  are duplicates.  Datacompy sorts by the other fields before generating the temporary ID, then matches
+  directly on that field.  If there are a lot of duplicates you may need to join on more columns, or
+  handle them separately.
 
 Limitations
 -----------

Issue with nulls in join columns when de-duping
There's an issue with the logic that tries to handle duplicate data.  This is a minimal example:

```python
import numpy as np
import pandas as pd
import datacompy

df1 = pd.DataFrame({
    'fld_1': [1,2,2,3,3,4,5,5],
    'fld_2': ['A',np.nan,np.nan,np.nan,np.nan,np.nan,np.nan,np.nan]})

df2 = pd.DataFrame({
    'fld_1': [1,2,3,4,5],
    'fld_2': ['A',np.nan,np.nan,np.nan,np.nan]})

comp = datacompy.Compare(df1, df2, join_columns=['fld_1', 'fld_2'])
assert comp.subset()
```

`df2` is a subset of `df1`, but it's not matching.  The issue is [here](https://github.com/capitalone/datacompy/blob/master/datacompy/core.py#L220), the `groupby.cumcount` just lumps any rows with nulls in them into one big category.  Need to make that more resilient to nulls.
diff --git a/tests/test_core.py b/tests/test_core.py
index 7e37ac4..232dc4e 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -17,7 +17,7 @@
 """
 Testing out the datacompy functionality
 """
-
+from datetime import datetime
 from decimal import Decimal
 import pytest
 from pytest import raises
@@ -124,8 +124,9 @@ something||False
 |something|False
 ||True"""
     df = pd.read_csv(six.StringIO(data), sep="|")
-    actual_out = datacompy.columns_equal(df.a, df.b, rel_tol=0.2, ignore_spaces=True,
-                                         ignore_case=True)
+    actual_out = datacompy.columns_equal(
+        df.a, df.b, rel_tol=0.2, ignore_spaces=True, ignore_case=True
+    )
     expect_out = df["expected"]
     assert_series_equal(expect_out, actual_out, check_names=False)
 
@@ -190,8 +191,9 @@ def test_date_columns_equal_with_ignore_spaces_and_case():
 ||True"""
     df = pd.read_csv(six.StringIO(data), sep="|")
     # First compare just the strings
-    actual_out = datacompy.columns_equal(df.a, df.b, rel_tol=0.2, ignore_spaces=True,
-                                         ignore_case=True)
+    actual_out = datacompy.columns_equal(
+        df.a, df.b, rel_tol=0.2, ignore_spaces=True, ignore_case=True
+    )
     expect_out = df["expected"]
     assert_series_equal(expect_out, actual_out, check_names=False)
 
@@ -735,7 +737,6 @@ def test_strings_with_joins_with_ignore_spaces():
     assert compare.intersect_rows_match()
 
 
-
 def test_strings_with_joins_with_ignore_case():
     df1 = pd.DataFrame([{"a": "hi", "b": "a"}, {"a": "bye", "b": "A"}])
     df2 = pd.DataFrame([{"a": "hi", "b": "A"}, {"a": "bye", "b": "a"}])
@@ -851,3 +852,47 @@ def test_calculate_max_diff(column, expected):
     assert np.isclose(
         datacompy.calculate_max_diff(MAX_DIFF_DF["base"], MAX_DIFF_DF[column]), expected
     )
+
+
+def test_dupes_with_nulls():
+    df1 = pd.DataFrame(
+        {
+            "fld_1": [1, 2, 2, 3, 3, 4, 5, 5],
+            "fld_2": ["A", np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan],
+        }
+    )
+    df2 = pd.DataFrame({"fld_1": [1, 2, 3, 4, 5], "fld_2": ["A", np.nan, np.nan, np.nan, np.nan]})
+    comp = datacompy.Compare(df1, df2, join_columns=["fld_1", "fld_2"])
+    assert comp.subset()
+
+
+@pytest.mark.parametrize(
+    "dataframe,expected",
+    [
+        (pd.DataFrame({"a": [1, 2, 3], "b": [1, 2, 3]}), pd.Series([0, 0, 0])),
+        (pd.DataFrame({"a": ["a", "a", "DATACOMPY_NULL"], "b": [1, 1, 2]}), pd.Series([0, 1, 0])),
+        (pd.DataFrame({"a": [-999, 2, 3], "b": [1, 2, 3]}), pd.Series([0, 0, 0])),
+        (pd.DataFrame({"a": [1, np.nan, np.nan], "b": [1, 2, 2]}), pd.Series([0, 0, 1])),
+        (pd.DataFrame({"a": ["1", np.nan, np.nan], "b": ["1", "2", "2"]}), pd.Series([0, 0, 1])),
+        (
+            pd.DataFrame({"a": [datetime(2018, 1, 1), np.nan, np.nan], "b": ["1", "2", "2"]}),
+            pd.Series([0, 0, 1]),
+        ),
+    ],
+)
+def test_generate_id_within_group(dataframe, expected):
+    assert (datacompy.core.generate_id_within_group(dataframe, ["a", "b"]) == expected).all()
+
+
+@pytest.mark.parametrize(
+    "dataframe, message",
+    [
+        (
+            pd.DataFrame({"a": [1, np.nan, "DATACOMPY_NULL"], "b": [1, 2, 3]}),
+            "DATACOMPY_NULL was found in your join columns",
+        )
+    ],
+)
+def test_generate_id_within_group_valueerror(dataframe, message):
+    with raises(ValueError, message=message):
+        datacompy.core.generate_id_within_group(dataframe, ["a", "b"])

pip install -e .
parse_log_pytest
requirements.txt
pytest>=3.0.6 Sphinx>=1.6.2 sphinx-rtd-theme>=0.2.4 numpydoc>=0.6.0 mock>=2.0.0 pre-commit>=1.10.4 pytest
3.6
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==22.2.0
Babel==2.11.0
certifi==2021.5.30
cfgv==3.3.1
charset-normalizer==2.0.12
-e git+https://github.com/capitalone/datacompy.git@46748b8842589c444c1c14d0bd76ba60f1306f20#egg=datacompy
distlib==0.3.9
docutils==0.18.1
filelock==3.4.1
identify==2.4.4
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
importlib-resources==5.2.3
iniconfig==1.1.1
Jinja2==3.0.3
MarkupSafe==2.0.1
mock==5.2.0
nodeenv==1.6.0
numpy==1.19.5
numpydoc==1.1.0
packaging==21.3
pandas==1.1.5
platformdirs==2.4.0
pluggy==1.0.0
pre-commit==2.17.0
py==1.11.0
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
python-dateutil==2.9.0.post0
pytz==2025.2
PyYAML==6.0.1
requests==2.27.1
six==1.17.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml==0.10.2
tomli==1.2.3
typing_extensions==4.1.1
urllib3==1.26.20
virtualenv==20.16.2
zipp==3.6.0

name: datacompy
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==22.2.0
      - babel==2.11.0
      - cfgv==3.3.1
      - charset-normalizer==2.0.12
      - distlib==0.3.9
      - docutils==0.18.1
      - filelock==3.4.1
      - identify==2.4.4
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - importlib-resources==5.2.3
      - iniconfig==1.1.1
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - mock==5.2.0
      - nodeenv==1.6.0
      - numpy==1.19.5
      - numpydoc==1.1.0
      - packaging==21.3
      - pandas==1.1.5
      - platformdirs==2.4.0
      - pluggy==1.0.0
      - pre-commit==2.17.0
      - py==1.11.0
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyyaml==6.0.1
      - requests==2.27.1
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - toml==0.10.2
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - virtualenv==20.16.2
      - zipp==3.6.0
prefix: /opt/conda/envs/datacompy

tests/test_core.py::test_dupes_with_nulls tests/test_core.py::test_generate_id_within_group[dataframe0-expected0] tests/test_core.py::test_generate_id_within_group[dataframe1-expected1] tests/test_core.py::test_generate_id_within_group[dataframe2-expected2] tests/test_core.py::test_generate_id_within_group[dataframe3-expected3] tests/test_core.py::test_generate_id_within_group[dataframe4-expected4] tests/test_core.py::test_generate_id_within_group[dataframe5-expected5]
tests/test_core.py::test_compare_df_setter_bad tests/test_core.py::test_compare_df_setter_bad_index tests/test_core.py::test_compare_on_index_and_join_columns tests/test_core.py::test_decimal_with_joins tests/test_core.py::test_generate_id_within_group_valueerror[dataframe0-DATACOMPY_NULL
tests/test_core.py::test_numeric_columns_equal_abs tests/test_core.py::test_numeric_columns_equal_rel tests/test_core.py::test_string_columns_equal tests/test_core.py::test_string_columns_equal_with_ignore_spaces tests/test_core.py::test_string_columns_equal_with_ignore_spaces_and_case tests/test_core.py::test_date_columns_equal tests/test_core.py::test_date_columns_equal_with_ignore_spaces tests/test_core.py::test_date_columns_equal_with_ignore_spaces_and_case tests/test_core.py::test_date_columns_unequal tests/test_core.py::test_bad_date_columns tests/test_core.py::test_rounded_date_columns tests/test_core.py::test_decimal_float_columns_equal tests/test_core.py::test_decimal_float_columns_equal_rel tests/test_core.py::test_decimal_columns_equal tests/test_core.py::test_decimal_columns_equal_rel tests/test_core.py::test_infinity_and_beyond tests/test_core.py::test_mixed_column tests/test_core.py::test_mixed_column_with_ignore_spaces tests/test_core.py::test_mixed_column_with_ignore_spaces_and_case tests/test_core.py::test_compare_df_setter_good tests/test_core.py::test_compare_df_setter_different_cases tests/test_core.py::test_compare_df_setter_good_index tests/test_core.py::test_columns_overlap tests/test_core.py::test_columns_no_overlap tests/test_core.py::test_10k_rows tests/test_core.py::test_subset tests/test_core.py::test_not_subset tests/test_core.py::test_large_subset tests/test_core.py::test_string_joiner tests/test_core.py::test_decimal_with_nulls tests/test_core.py::test_strings_with_joins tests/test_core.py::test_index_joining tests/test_core.py::test_index_joining_strings_i_guess tests/test_core.py::test_index_joining_non_overlapping tests/test_core.py::test_temp_column_name tests/test_core.py::test_temp_column_name_one_has tests/test_core.py::test_temp_column_name_both_have tests/test_core.py::test_temp_column_name_one_already tests/test_core.py::test_simple_dupes_one_field tests/test_core.py::test_simple_dupes_two_fields tests/test_core.py::test_simple_dupes_index tests/test_core.py::test_simple_dupes_one_field_two_vals tests/test_core.py::test_simple_dupes_one_field_three_to_two_vals tests/test_core.py::test_dupes_from_real_data tests/test_core.py::test_strings_with_joins_with_ignore_spaces tests/test_core.py::test_strings_with_joins_with_ignore_case tests/test_core.py::test_decimal_with_joins_with_ignore_spaces tests/test_core.py::test_decimal_with_joins_with_ignore_case tests/test_core.py::test_index_with_joins_with_ignore_spaces tests/test_core.py::test_index_with_joins_with_ignore_case tests/test_core.py::test_calculate_max_diff[base-0] tests/test_core.py::test_calculate_max_diff[floats-0.2] tests/test_core.py::test_calculate_max_diff[decimals-0.1] tests/test_core.py::test_calculate_max_diff[null_floats-0.1] tests/test_core.py::test_calculate_max_diff[strings-0.1] tests/test_core.py::test_calculate_max_diff[mixed_strings-0] tests/test_core.py::test_calculate_max_diff[infinity-inf]
sphinx/pandas_usage.rst datacompy/core.py datacompy/_version.py
sphinx/pandas_usage.rst datacompy/core.py datacompy/_version.py