Repository: capitalone/datacompy
Pull request #393 (2025-03-14 19:46:25)

Release v0.16.4
Release v0.16.4
----------------

Bug fixes:
- Categorical type compares (#392)
- intersection of rows with no mutual connection (#385, #388)


Patch:
diff --git a/datacompy/__init__.py b/datacompy/__init__.py
index 966b160..2be9be7 100644
--- a/datacompy/__init__.py
+++ b/datacompy/__init__.py
@@ -18,7 +18,7 @@ Originally started to be something of a replacement for SAS's PROC COMPARE for P
 Then extended to carry that functionality over to Spark Dataframes.
 """
 
-__version__ = "0.16.3"
+__version__ = "0.16.4"
 
 import platform
 from warnings import warn
diff --git a/datacompy/base.py b/datacompy/base.py
index bf0305b..c9e3797 100644
--- a/datacompy/base.py
+++ b/datacompy/base.py
@@ -158,6 +158,10 @@ class BaseCompare(ABC):
         """Return a string representation of a report."""
         pass
 
+    def only_join_columns(self) -> bool:
+        """Boolean on if the only columns are the join columns."""
+        return set(self.join_columns) == set(self.df1.columns) == set(self.df2.columns)
+
 
 def temp_column_name(*dataframes) -> str:
     """Get a temp column name that isn't included in columns of any dataframes.
diff --git a/datacompy/core.py b/datacompy/core.py
index 3313b23..caba7d6 100644
--- a/datacompy/core.py
+++ b/datacompy/core.py
@@ -340,14 +340,22 @@ class Compare(BaseCompare):
         otherwise.
         """
         LOG.debug("Comparing intersection")
-        row_cnt = len(self.intersect_rows)
         for column in self.intersect_columns():
             if column in self.join_columns:
-                match_cnt = row_cnt
-                col_match = ""
+                col_match = column + "_match"
+                match_cnt = len(self.intersect_rows)
+                if not self.only_join_columns():
+                    row_cnt = len(self.intersect_rows)
+                else:
+                    row_cnt = (
+                        len(self.intersect_rows)
+                        + len(self.df1_unq_rows)
+                        + len(self.df2_unq_rows)
+                    )
                 max_diff = 0.0
                 null_diff = 0
             else:
+                row_cnt = len(self.intersect_rows)
                 col_1 = column + "_" + self.df1_name
                 col_2 = column + "_" + self.df2_name
                 col_match = column + "_match"
@@ -428,6 +436,8 @@ class Compare(BaseCompare):
 
     def intersect_rows_match(self) -> bool:
         """Check whether the intersect rows all match."""
+        if self.intersect_rows.empty:
+            return False
         actual_length = self.intersect_rows.shape[0]
         return self.count_matching_rows() == actual_length
 
@@ -470,7 +480,7 @@ class Compare(BaseCompare):
 
     def sample_mismatch(
         self, column: str, sample_count: int = 10, for_display: bool = False
-    ) -> pd.DataFrame:
+    ) -> pd.DataFrame | None:
         """Return sample mismatches.
 
         Gets a sub-dataframe which contains the identifying
@@ -492,27 +502,53 @@ class Compare(BaseCompare):
             A sample of the intersection dataframe, containing only the
             "pertinent" columns, for rows that don't match on the provided
             column.
+
+        None
+            When the column being requested is not an intersecting column between dataframes.
         """
-        row_cnt = self.intersect_rows.shape[0]
-        col_match = self.intersect_rows[column + "_match"]
-        match_cnt = col_match.sum()
-        sample_count = min(sample_count, row_cnt - match_cnt)
-        sample = self.intersect_rows[~col_match].sample(sample_count)
-        return_cols = [
-            *self.join_columns,
-            column + "_" + self.df1_name,
-            column + "_" + self.df2_name,
-        ]
-        to_return = sample[return_cols]
-        if for_display:
-            to_return.columns = pd.Index(
-                [
-                    *self.join_columns,
-                    column + " (" + self.df1_name + ")",
-                    column + " (" + self.df2_name + ")",
-                ]
+        if not self.only_join_columns() and column not in self.join_columns:
+            row_cnt = self.intersect_rows.shape[0]
+            try:
+                col_match = self.intersect_rows[column + "_match"]
+            except KeyError:
+                LOG.error(
+                    f"Column: {column} is not an intersecting column. No mismatches can be generated."
+                )
+                return None
+            match_cnt = col_match.sum()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            sample = self.intersect_rows[~col_match].sample(sample_count)
+            return_cols = [
+                *self.join_columns,
+                column + "_" + self.df1_name,
+                column + "_" + self.df2_name,
+            ]
+            to_return = sample[return_cols]
+            if for_display:
+                to_return.columns = pd.Index(
+                    [
+                        *self.join_columns,
+                        column + " (" + self.df1_name + ")",
+                        column + " (" + self.df2_name + ")",
+                    ]
+                )
+            return to_return
+        else:
+            row_cnt = (
+                len(self.intersect_rows)
+                + len(self.df1_unq_rows)
+                + len(self.df2_unq_rows)
             )
-        return to_return
+            col_match = self.intersect_rows[column]
+            match_cnt = col_match.count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            sample = pd.concat(
+                [self.df1_unq_rows[[column]], self.df2_unq_rows[[column]]]
+            ).sample(sample_count)
+            to_return = sample
+            if for_display:
+                to_return.columns = pd.Index([column])
+            return to_return
 
     def all_mismatch(self, ignore_matching_cols: bool = False) -> pd.DataFrame:
         """Get all rows with any columns that have a mismatch.
@@ -532,6 +568,10 @@ class Compare(BaseCompare):
         """
         match_list = []
         return_list = []
+        if self.only_join_columns():
+            LOG.info("Only join keys in data, returning mismatches based on unq_rows")
+            return pd.concat([self.df1_unq_rows, self.df2_unq_rows])
+
         for col in self.intersect_rows.columns:
             if col.endswith("_match"):
                 orig_col_name = col[:-6]
@@ -560,6 +600,14 @@ class Compare(BaseCompare):
                     LOG.debug(
                         f"Column {orig_col_name} is equal in df1 and df2. It will not be added to the result."
                     )
+        if len(match_list) == 0:
+            LOG.info("No match columns found, returning mismatches based on unq_rows")
+            return pd.concat(
+                [
+                    self.df1_unq_rows[self.join_columns],
+                    self.df2_unq_rows[self.join_columns],
+                ]
+            )
 
         mm_bool = self.intersect_rows[match_list].all(axis="columns")
         return self.intersect_rows[~mm_bool][self.join_columns + return_list]
@@ -851,13 +899,9 @@ def columns_equal(
                         | (col_1.isnull() & col_2.isnull())
                     )
             except Exception:
-                # Check for string[pyarrow] and string[python]
-                if col_1.dtype in (
-                    "string[python]",
-                    "string[pyarrow]",
-                ) and col_2.dtype in ("string[python]", "string[pyarrow]"):
+                try:
                     compare = pd.Series(col_1.astype(str) == col_2.astype(str))
-                else:  # Blanket exception should just return all False
+                except Exception:  # Blanket exception should just return all False
                     compare = pd.Series(False, index=col_1.index)
     compare.index = col_1.index
     return compare
diff --git a/datacompy/polars.py b/datacompy/polars.py
index 405901e..54f9068 100644
--- a/datacompy/polars.py
+++ b/datacompy/polars.py
@@ -83,8 +83,8 @@ class PolarsCompare(BaseCompare):
 
     def __init__(
         self,
-        df1: "pl.DataFrame",
-        df2: "pl.DataFrame",
+        df1: pl.DataFrame,
+        df2: pl.DataFrame,
         join_columns: List[str] | str,
         abs_tol: float = 0,
         rel_tol: float = 0,
@@ -126,12 +126,12 @@ class PolarsCompare(BaseCompare):
         self._compare(ignore_spaces=ignore_spaces, ignore_case=ignore_case)
 
     @property
-    def df1(self) -> "pl.DataFrame":
+    def df1(self) -> pl.DataFrame:
         """Get the first dataframe."""
         return self._df1
 
     @df1.setter
-    def df1(self, df1: "pl.DataFrame") -> None:
+    def df1(self, df1: pl.DataFrame) -> None:
         """Check that it is a dataframe and has the join columns."""
         self._df1 = df1
         self._validate_dataframe(
@@ -139,12 +139,12 @@ class PolarsCompare(BaseCompare):
         )
 
     @property
-    def df2(self) -> "pl.DataFrame":
+    def df2(self) -> pl.DataFrame:
         """Get the second dataframe."""
         return self._df2
 
     @df2.setter
-    def df2(self, df2: "pl.DataFrame") -> None:
+    def df2(self, df2: pl.DataFrame) -> None:
         """Check that it is a dataframe and has the join columns."""
         self._df2 = df2
         self._validate_dataframe(
@@ -331,14 +331,22 @@ class PolarsCompare(BaseCompare):
         null_diff: int | float
 
         LOG.debug("Comparing intersection")
-        row_cnt = len(self.intersect_rows)
         for column in self.intersect_columns():
             if column in self.join_columns:
-                match_cnt = row_cnt
-                col_match = ""
+                col_match = column + "_match"
+                match_cnt = len(self.intersect_rows)
+                if not self.only_join_columns():
+                    row_cnt = len(self.intersect_rows)
+                else:
+                    row_cnt = (
+                        len(self.intersect_rows)
+                        + len(self.df1_unq_rows)
+                        + len(self.df2_unq_rows)
+                    )
                 max_diff = 0.0
                 null_diff = 0
             else:
+                row_cnt = len(self.intersect_rows)
                 col_1 = column + "_" + self.df1_name
                 col_2 = column + "_" + self.df2_name
                 col_match = column + "_match"
@@ -429,6 +437,8 @@ class PolarsCompare(BaseCompare):
 
     def intersect_rows_match(self) -> bool:
         """Check whether the intersect rows all match."""
+        if self.intersect_rows.is_empty():
+            return False
         actual_length = self.intersect_rows.shape[0]
         return self.count_matching_rows() == actual_length
 
@@ -471,7 +481,7 @@ class PolarsCompare(BaseCompare):
 
     def sample_mismatch(
         self, column: str, sample_count: int = 10, for_display: bool = False
-    ) -> "pl.DataFrame":
+    ) -> pl.DataFrame | None:
         """Return sample mismatches.
 
         Get a sub-dataframe which contains the identifying
@@ -493,29 +503,46 @@ class PolarsCompare(BaseCompare):
             A sample of the intersection dataframe, containing only the
             "pertinent" columns, for rows that don't match on the provided
             column.
+
+        None
+            When the column being requested is not an intersecting column between dataframes.
         """
-        row_cnt = self.intersect_rows.shape[0]
-        col_match = self.intersect_rows[column + "_match"]
-        match_cnt = col_match.sum()
-        sample_count = min(sample_count, row_cnt - match_cnt)  # type: ignore
-        sample = self.intersect_rows.filter(
-            pl.col(column + "_match") != True  # noqa: E712
-        ).sample(sample_count)
-        return_cols = [
-            *self.join_columns,
-            column + "_" + self.df1_name,
-            column + "_" + self.df2_name,
-        ]
-        to_return = sample[return_cols]
-        if for_display:
-            to_return.columns = [
+        if not self.only_join_columns() and column not in self.join_columns:
+            row_cnt = self.intersect_rows.shape[0]
+            col_match = self.intersect_rows[column + "_match"]
+            match_cnt = col_match.sum()
+            sample_count = min(sample_count, row_cnt - match_cnt)  # type: ignore
+            sample = self.intersect_rows.filter(
+                pl.col(column + "_match") != True  # noqa: E712
+            ).sample(sample_count)
+            return_cols = [
                 *self.join_columns,
-                column + " (" + self.df1_name + ")",
-                column + " (" + self.df2_name + ")",
+                column + "_" + self.df1_name,
+                column + "_" + self.df2_name,
             ]
-        return to_return
-
-    def all_mismatch(self, ignore_matching_cols: bool = False) -> "pl.DataFrame":
+            to_return = sample[return_cols]
+            if for_display:
+                to_return.columns = [
+                    *self.join_columns,
+                    column + " (" + self.df1_name + ")",
+                    column + " (" + self.df2_name + ")",
+                ]
+            return to_return
+        else:
+            row_cnt = (
+                len(self.intersect_rows)
+                + len(self.df1_unq_rows)
+                + len(self.df2_unq_rows)
+            )
+            col_match = self.intersect_rows[column]
+            match_cnt = col_match.count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            sample = pl.concat(
+                [self.df1_unq_rows[[column]], self.df2_unq_rows[[column]]]
+            ).sample(sample_count)
+            return sample
+
+    def all_mismatch(self, ignore_matching_cols: bool = False) -> pl.DataFrame:
         """Get all rows with any columns that have a mismatch.
 
         Returns all df1 and df2 versions of the columns and join
@@ -533,6 +560,10 @@ class PolarsCompare(BaseCompare):
         """
         match_list = []
         return_list = []
+        if self.only_join_columns():
+            LOG.info("Only join keys in data, returning mismatches based on unq_rows")
+            return pl.concat([self.df1_unq_rows, self.df2_unq_rows])
+
         for col in self.intersect_rows.columns:
             if col.endswith("_match"):
                 orig_col_name = col[:-6]
@@ -561,6 +592,15 @@ class PolarsCompare(BaseCompare):
                     LOG.debug(
                         f"Column {orig_col_name} is equal in df1 and df2. It will not be added to the result."
                     )
+        if len(match_list) == 0:
+            LOG.info("No match columns found, returning mismatches based on unq_rows")
+            return pl.concat(
+                [
+                    self.df1_unq_rows.select(self.join_columns),
+                    self.df2_unq_rows.select(self.join_columns),
+                ]
+            )
+
         return (
             self.intersect_rows.with_columns(__all=pl.all_horizontal(match_list))
             .filter(pl.col("__all") != True)  # noqa: E712
@@ -595,7 +635,7 @@ class PolarsCompare(BaseCompare):
             The report, formatted kinda nicely.
         """
 
-        def df_to_str(pdf: "pl.DataFrame") -> str:
+        def df_to_str(pdf: pl.DataFrame) -> str:
             return pdf.to_pandas().to_string()
 
         # Header
@@ -887,7 +927,7 @@ def compare_string_and_date_columns(
 
 
 def get_merged_columns(
-    original_df: "pl.DataFrame", merged_df: "pl.DataFrame", suffix: str
+    original_df: pl.DataFrame, merged_df: pl.DataFrame, suffix: str
 ) -> List[str]:
     """Get the columns from an original dataframe, in the new merged dataframe.
 
@@ -936,7 +976,7 @@ def calculate_max_diff(col_1: "pl.Series", col_2: "pl.Series") -> float:
 
 
 def generate_id_within_group(
-    dataframe: "pl.DataFrame", join_columns: List[str]
+    dataframe: pl.DataFrame, join_columns: List[str]
 ) -> "pl.Series":
     """Generate an ID column that can be used to deduplicate identical rows.
 
diff --git a/datacompy/snowflake.py b/datacompy/snowflake.py
index c7fe1ce..dbd8e2e 100644
--- a/datacompy/snowflake.py
+++ b/datacompy/snowflake.py
@@ -39,6 +39,7 @@ try:
     import snowflake.snowpark as sp
     from snowflake.connector.errors import DatabaseError, ProgrammingError
     from snowflake.snowpark import Window
+    from snowflake.snowpark.exceptions import SnowparkSQLException
     from snowflake.snowpark.functions import (
         abs,
         col,
@@ -425,20 +426,17 @@ class SnowflakeCompare(BaseCompare):
                     self.abs_tol,
                     ignore_spaces,
                 )
-        row_cnt = self.intersect_rows.count()
 
         with ThreadPoolExecutor() as executor:
             futures = []
             for column in self.intersect_columns():
-                future = executor.submit(
-                    self._calculate_column_compare_stats, column, row_cnt
-                )
+                future = executor.submit(self._calculate_column_compare_stats, column)
                 futures.append(future)
             for future in as_completed(futures):
                 if future.exception():
                     raise future.exception()
 
-    def _calculate_column_compare_stats(self, column: str, row_cnt: int) -> None:
+    def _calculate_column_compare_stats(self, column: str) -> None:
         """Populate the column stats for all intersecting column pairs.
 
         Calculates compare stats by intersecting column pairs. For the non-trivial case
@@ -446,11 +444,20 @@ class SnowflakeCompare(BaseCompare):
         and null difference must be calculated.
         """
         if column in self.join_columns:
-            match_cnt = row_cnt
-            col_match = ""
+            col_match = column + "_MATCH"
+            match_cnt = self.intersect_rows.count()
+            if not self.only_join_columns():
+                row_cnt = self.intersect_rows.count()
+            else:
+                row_cnt = (
+                    self.intersect_rows.count()
+                    + self.df1_unq_rows.count()
+                    + self.df2_unq_rows.count()
+                )
             max_diff = 0
             null_diff = 0
         else:
+            row_cnt = self.intersect_rows.count()
             col_1 = column + "_" + self.df1_name
             col_2 = column + "_" + self.df2_name
             col_match = column + "_MATCH"
@@ -551,6 +558,8 @@ class SnowflakeCompare(BaseCompare):
 
     def intersect_rows_match(self) -> bool:
         """Check whether the intersect rows all match."""
+        if self.intersect_rows.count() == 0:
+            return False
         actual_length = self.intersect_rows.count()
         return self.count_matching_rows() == actual_length
 
@@ -616,37 +625,62 @@ class SnowflakeCompare(BaseCompare):
             "pertinent" columns, for rows that don't match on the provided
             column.
         """
-        row_cnt = self.intersect_rows.count()
-        col_match = self.intersect_rows.select(column + "_MATCH")
-        match_cnt = col_match.where(
-            col(column + "_MATCH") == True  # noqa: E712
-        ).count()
-        sample_count = min(sample_count, row_cnt - match_cnt)
-        sample = (
-            self.intersect_rows.where(col(column + "_MATCH") == False)  # noqa: E712
-            .drop(column + "_MATCH")
-            .limit(sample_count)
-        )
+        column = column.upper()
+        if not self.only_join_columns() and column not in self.join_columns:
+            row_cnt = self.intersect_rows.count()
+            col_match = self.intersect_rows.select(column + "_MATCH")
+            try:
+                col_match.collect()
+            except SnowparkSQLException:
+                LOG.error(
+                    f"Column: {column} is not an intersecting column. No mismatches can be generated."
+                )
+                return None
+            match_cnt = col_match.where(
+                col(column + "_MATCH") == True  # noqa: E712
+            ).count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            sample = (
+                self.intersect_rows.where(col(column + "_MATCH") == False)  # noqa: E712
+                .drop(column + "_MATCH")
+                .limit(sample_count)
+            )
 
-        for c in self.join_columns:
-            sample = sample.withColumnRenamed(c + "_" + self.df1_name, c)
-
-        return_cols = [
-            *self.join_columns,
-            column + "_" + self.df1_name,
-            column + "_" + self.df2_name,
-        ]
-        to_return = sample.select(return_cols)
-
-        if for_display:
-            return to_return.toDF(
-                *[
-                    *self.join_columns,
-                    column + " (" + self.df1_name + ")",
-                    column + " (" + self.df2_name + ")",
-                ]
+            for c in self.join_columns:
+                sample = sample.withColumnRenamed(c + "_" + self.df1_name, c)
+
+            return_cols = [
+                *self.join_columns,
+                column + "_" + self.df1_name,
+                column + "_" + self.df2_name,
+            ]
+            to_return = sample.select(return_cols)
+
+            if for_display:
+                return to_return.toDF(
+                    *[
+                        *self.join_columns,
+                        column + " (" + self.df1_name + ")",
+                        column + " (" + self.df2_name + ")",
+                    ]
+                )
+            return to_return
+        else:
+            row_cnt = (
+                self.intersect_rows.count()
+                + self.df1_unq_rows.count()
+                + self.df2_unq_rows.count()
             )
-        return to_return
+            match_cnt = self.intersect_rows.count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            df1_col = column + "_" + self.df1_name
+            df2_col = column + "_" + self.df2_name
+            sample = (
+                self.df1_unq_rows[[df1_col]]
+                .union_all(self.df2_unq_rows[[df2_col]])
+                .limit(sample_count)
+            )
+            return sample.toDF(column)
 
     def all_mismatch(self, ignore_matching_cols: bool = False) -> "sp.DataFrame":
         """Get all rows with any columns that have a mismatch.
@@ -666,6 +700,16 @@ class SnowflakeCompare(BaseCompare):
         """
         match_list = []
         return_list = []
+        if self.only_join_columns():
+            LOG.info("Only join keys in data, returning mismatches based on unq_rows")
+            df1_cols = [f"{cols}_{self.df1_name}" for cols in self.join_columns]
+            df2_cols = [f"{cols}_{self.df2_name}" for cols in self.join_columns]
+            to_return = self.df1_unq_rows[df1_cols].union_all(
+                self.df2_unq_rows[df2_cols]
+            )
+            for c in self.join_columns:
+                to_return = to_return.withColumnRenamed(c + "_" + self.df1_name, c)
+            return to_return
         for c in self.intersect_rows.columns:
             if c.endswith("_MATCH"):
                 orig_col_name = c[:-6]
@@ -699,7 +743,16 @@ class SnowflakeCompare(BaseCompare):
                     LOG.debug(
                         f"Column {orig_col_name} is equal in df1 and df2. It will not be added to the result."
                     )
-
+        if len(match_list) == 0:
+            LOG.info("No match columns found, returning mismatches based on unq_rows")
+            df1_cols = [f"{cols}_{self.df1_name}" for cols in self.join_columns]
+            df2_cols = [f"{cols}_{self.df2_name}" for cols in self.join_columns]
+            to_return = self.df1_unq_rows[df1_cols].union_all(
+                self.df2_unq_rows[df2_cols]
+            )
+            for c in self.join_columns:
+                to_return = to_return.withColumnRenamed(c + "_" + self.df1_name, c)
+            return to_return
         mm_rows = self.intersect_rows.withColumn(
             "match_array", concat(*match_list)
         ).where(contains(col("match_array"), lit("false")))
diff --git a/datacompy/spark/sql.py b/datacompy/spark/sql.py
index 80dcba1..c8faf5b 100644
--- a/datacompy/spark/sql.py
+++ b/datacompy/spark/sql.py
@@ -459,14 +459,22 @@ class SparkSQLCompare(BaseCompare):
         LOG.debug("Comparing intersection")
         max_diff: float
         null_diff: int
-        row_cnt = self.intersect_rows.count()
         for column in self.intersect_columns():
             if column in self.join_columns:
-                match_cnt = row_cnt
-                col_match = ""
+                col_match = column + "_match"
+                match_cnt = self.intersect_rows.count()
+                if not self.only_join_columns():
+                    row_cnt = self.intersect_rows.count()
+                else:
+                    row_cnt = (
+                        self.intersect_rows.count()
+                        + self.df1_unq_rows.count()
+                        + self.df2_unq_rows.count()
+                    )
                 max_diff = 0
                 null_diff = 0
             else:
+                row_cnt = self.intersect_rows.count()
                 col_1 = column + "_" + self.df1_name
                 col_2 = column + "_" + self.df2_name
                 col_match = column + "_match"
@@ -561,6 +569,8 @@ class SparkSQLCompare(BaseCompare):
 
     def intersect_rows_match(self) -> bool:
         """Check whether the intersect rows all match."""
+        if self.intersect_rows.count() == 0:
+            return False
         actual_length = self.intersect_rows.count()
         return self.count_matching_rows() == actual_length
 
@@ -621,37 +631,54 @@ class SparkSQLCompare(BaseCompare):
             "pertinent" columns, for rows that don't match on the provided
             column.
         """
-        row_cnt = self.intersect_rows.count()
-        col_match = self.intersect_rows.select(column + "_match")
-        match_cnt = col_match.where(
-            col(column + "_match") == True  # noqa: E712
-        ).count()
-        sample_count = min(sample_count, row_cnt - match_cnt)
-        sample = (
-            self.intersect_rows.where(col(column + "_match") == False)  # noqa: E712
-            .drop(column + "_match")
-            .limit(sample_count)
-        )
-
-        for c in self.join_columns:
-            sample = sample.withColumnRenamed(c + "_" + self.df1_name, c)
+        if not self.only_join_columns() and column not in self.join_columns:
+            row_cnt = self.intersect_rows.count()
+            col_match = self.intersect_rows.select(column + "_match")
+            match_cnt = col_match.where(
+                col(column + "_match") == True  # noqa: E712
+            ).count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            sample = (
+                self.intersect_rows.where(col(column + "_match") == False)  # noqa: E712
+                .drop(column + "_match")
+                .limit(sample_count)
+            )
 
-        return_cols = [
-            *self.join_columns,
-            column + "_" + self.df1_name,
-            column + "_" + self.df2_name,
-        ]
-        to_return = sample.select(return_cols)
+            for c in self.join_columns:
+                sample = sample.withColumnRenamed(c + "_" + self.df1_name, c)
 
-        if for_display:
-            return to_return.toDF(
-                *[
-                    *self.join_columns,
-                    column + " (" + self.df1_name + ")",
-                    column + " (" + self.df2_name + ")",
-                ]
+            return_cols = [
+                *self.join_columns,
+                column + "_" + self.df1_name,
+                column + "_" + self.df2_name,
+            ]
+            to_return = sample.select(return_cols)
+
+            if for_display:
+                return to_return.toDF(
+                    *[
+                        *self.join_columns,
+                        column + " (" + self.df1_name + ")",
+                        column + " (" + self.df2_name + ")",
+                    ]
+                )
+            return to_return
+        else:
+            row_cnt = (
+                self.intersect_rows.count()
+                + self.df1_unq_rows.count()
+                + self.df2_unq_rows.count()
+            )
+            match_cnt = self.intersect_rows.count()
+            sample_count = min(sample_count, row_cnt - match_cnt)
+            df1_col = column + "_" + self.df1_name
+            df2_col = column + "_" + self.df2_name
+            sample = (
+                self.df1_unq_rows[[df1_col]]
+                .union(self.df2_unq_rows[[df2_col]])
+                .limit(sample_count)
             )
-        return to_return
+            return sample.toDF(column)
 
     def all_mismatch(
         self, ignore_matching_cols: bool = False
@@ -673,6 +700,14 @@ class SparkSQLCompare(BaseCompare):
         """
         match_list = []
         return_list = []
+        if self.only_join_columns():
+            LOG.info("Only join keys in data, returning mismatches based on unq_rows")
+            df1_cols = [f"{cols}_{self.df1_name}" for cols in self.join_columns]
+            df2_cols = [f"{cols}_{self.df2_name}" for cols in self.join_columns]
+            to_return = self.df1_unq_rows[df1_cols].union(self.df2_unq_rows[df2_cols])
+            for c in self.join_columns:
+                to_return = to_return.withColumnRenamed(c + "_" + self.df1_name, c)
+            return to_return
         for c in self.intersect_rows.columns:
             if c.endswith("_match"):
                 orig_col_name = c[:-6]
@@ -707,6 +742,14 @@ class SparkSQLCompare(BaseCompare):
                     LOG.debug(
                         f"Column {orig_col_name} is equal in df1 and df2. It will not be added to the result."
                     )
+        if len(match_list) == 0:
+            LOG.info("No match columns found, returning mismatches based on unq_rows")
+            df1_cols = [f"{cols}_{self.df1_name}" for cols in self.join_columns]
+            df2_cols = [f"{cols}_{self.df2_name}" for cols in self.join_columns]
+            to_return = self.df1_unq_rows[df1_cols].union(self.df2_unq_rows[df2_cols])
+            for c in self.join_columns:
+                to_return = to_return.withColumnRenamed(c + "_" + self.df1_name, c)
+            return to_return
 
         mm_rows = self.intersect_rows.withColumn(
             "match_array", array(match_list)
diff --git a/pyproject.toml b/pyproject.toml
index 3fd0acc..a4e6d28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,7 @@ maintainers = [
   { name="Raymond Haffar", email="raymond.haffar@capitalone.com" },
 ]
 license = {text = "Apache Software License"}
-dependencies = ["pandas<=2.2.3,>=0.25.0", "numpy<=2.2.3,>=1.22.0", "ordered-set<=4.1.0,>=4.0.2", "polars[pandas]<=1.22.0,>=0.20.4"]
+dependencies = ["pandas<=2.2.3,>=0.25.0", "numpy<=2.2.3,>=1.22.0", "ordered-set<=4.1.0,>=4.0.2", "polars[pandas]<=1.23.0,>=0.20.4"]
 requires-python = ">=3.10.0"
 classifiers = [
     "Intended Audience :: Developers",


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index e3658f5..21bc194 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -28,7 +28,7 @@ import datacompy
 import numpy as np
 import pandas as pd
 import pytest
-from pandas.testing import assert_series_equal
+from pandas.testing import assert_frame_equal, assert_series_equal
 from pytest import raises
 
 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
@@ -437,6 +437,27 @@ def test_mixed_column_with_ignore_spaces_and_case():
     assert_series_equal(expect_out, actual_out, check_names=False)
 
 
+def test_categorical_column():
+    df = pd.DataFrame(
+        {
+            "idx": [1, 2, 3],
+            "foo": ["A", "B", np.nan],
+            "bar": ["A", "B", np.nan],
+        }
+    )
+    for col in ("foo", "bar"):
+        df[col] = df[col].astype("category")
+        df[col] = df[col].astype("category")
+
+    actual_out = datacompy.columns_equal(
+        df.foo, df.bar, ignore_spaces=True, ignore_case=True
+    )
+    assert actual_out.all()
+    compare = datacompy.Compare(df, df, join_columns=["idx"])
+    assert compare.intersect_rows["foo_match"].all()
+    assert compare.intersect_rows["bar_match"].all()
+
+
 def test_compare_df_setter_bad():
     df = pd.DataFrame([{"a": 1, "A": 2}, {"a": 2, "A": 2}])
     with raises(TypeError, match="df1 must be a pandas DataFrame"):
@@ -1370,3 +1391,109 @@ def test_save_html(mock_render):
         compare.report(html_file="test.html")
         assert mock_render.call_count == 4
         m.assert_called_with("test.html", "w")
+
+
+def test_full_join_counts_no_matches():
+    df1 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = pd.DataFrame([{"a": 1, "b": 4}, {"a": 1, "b": 5}])
+    compare = datacompy.Compare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort_index(),
+        pd.DataFrame([1, 1, 1, 1], columns=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort_index(),
+        pd.DataFrame([2, 3, 4, 5], columns=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch(),
+        pd.DataFrame(
+            [{"a": 1, "b": 2}, {"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 1, "b": 5}]
+        ),
+    )
+
+
+def test_full_join_counts_some_matches():
+    df1 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = pd.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 5}])
+    compare = datacompy.Compare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort_index().reset_index(drop=True),
+        pd.DataFrame([1, 1], columns=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort_index().reset_index(drop=True),
+        pd.DataFrame([3, 5], columns=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort_index().reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_no_matches():
+    df1 = pd.DataFrame([{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}])
+    df2 = pd.DataFrame([{"a": 1, "b": 4, "d": 5}, {"a": 1, "b": 5, "d": 5}])
+    compare = datacompy.Compare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort_index().reset_index(drop=True),
+        pd.DataFrame([1, 1, 1, 1], columns=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort_index().reset_index(drop=True),
+        pd.DataFrame([2, 3, 4, 5], columns=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort_index().reset_index(drop=True),
+        pd.DataFrame(
+            [{"a": 1, "b": 2}, {"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 1, "b": 5}]
+        ),
+    )
+
+
+def test_non_full_join_counts_some_matches():
+    df1 = pd.DataFrame([{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}])
+    df2 = pd.DataFrame([{"a": 1, "b": 2, "d": 5}, {"a": 1, "b": 5, "d": 5}])
+    compare = datacompy.Compare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort_index().reset_index(drop=True),
+        pd.DataFrame([1, 1], columns=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort_index().reset_index(drop=True),
+        pd.DataFrame([3, 5], columns=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort_index().reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
diff --git a/tests/test_polars.py b/tests/test_polars.py
index 1ee6122..476a4e3 100644
--- a/tests/test_polars.py
+++ b/tests/test_polars.py
@@ -39,7 +39,7 @@ from datacompy.polars import (
     temp_column_name,
 )
 from polars.exceptions import ComputeError, DuplicateError, SchemaError
-from polars.testing import assert_series_equal
+from polars.testing import assert_frame_equal, assert_series_equal
 
 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
 
@@ -1331,3 +1331,129 @@ def test_save_html(mock_render):
         compare.report(html_file="test.html")
         assert mock_render.call_count == 4
         m.assert_called_with("test.html", "w")
+
+
+def test_full_join_counts_no_matches():
+    df1 = pl.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = pl.DataFrame([{"a": 1, "b": 4}, {"a": 1, "b": 5}])
+    compare = PolarsCompare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort("a"),
+        pl.DataFrame([1, 1, 1, 1], schema=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort("b"),
+        pl.DataFrame([2, 3, 4, 5], schema=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort(["a", "b"]),
+        pl.DataFrame(
+            [{"a": 1, "b": 2}, {"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 1, "b": 5}]
+        ),
+    )
+
+
+def test_full_join_counts_some_matches():
+    df1 = pl.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = pl.DataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 5}])
+    compare = PolarsCompare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort("a"),
+        pl.DataFrame([1, 1], schema=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort("b"),
+        pl.DataFrame([3, 5], schema=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort(["a", "b"]),
+        pl.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_no_matches():
+    df1 = pl.DataFrame([{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}])
+    df2 = pl.DataFrame([{"a": 1, "b": 4, "d": 5}, {"a": 1, "b": 5, "d": 5}])
+    compare = PolarsCompare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort("a"),
+        pl.DataFrame([1, 1, 1, 1], schema=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort("b"),
+        pl.DataFrame([2, 3, 4, 5], schema=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort(["a", "b"]),
+        pl.DataFrame(
+            [{"a": 1, "b": 2}, {"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 1, "b": 5}]
+        ),
+    )
+
+
+def test_non_full_join_counts_some_matches():
+    df1 = pl.DataFrame([{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}])
+    df2 = pl.DataFrame([{"a": 1, "b": 2, "d": 5}, {"a": 1, "b": 5, "d": 5}])
+    compare = PolarsCompare(df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a").sort("a"),
+        pl.DataFrame([1, 1], schema=["a"]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b").sort("b"),
+        pl.DataFrame([3, 5], schema=["b"]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch().sort(["a", "b"]),
+        pl.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
+
+
+def test_categorical_column():
+    df = pl.DataFrame(
+        {
+            "idx": [1, 2, 3],
+            "foo": ["A", "B", np.nan],
+            "bar": ["A", "B", np.nan],
+        },
+        strict=False,
+        schema={"idx": pl.Int32, "foo": pl.Categorical, "bar": pl.Categorical},
+    )
+
+    actual_out = columns_equal(
+        df["foo"], df["bar"], ignore_spaces=True, ignore_case=True
+    )
+    assert actual_out.all()
+    compare = PolarsCompare(df, df, join_columns=["idx"])
+    assert compare.intersect_rows["foo_match"].all()
+    assert compare.intersect_rows["bar_match"].all()
diff --git a/tests/test_snowflake.py b/tests/test_snowflake.py
index a5387ec..56ed448 100644
--- a/tests/test_snowflake.py
+++ b/tests/test_snowflake.py
@@ -41,7 +41,7 @@ from datacompy.snowflake import (
     columns_equal,
     temp_column_name,
 )
-from pandas.testing import assert_series_equal
+from pandas.testing import assert_frame_equal, assert_series_equal
 from snowflake.snowpark.exceptions import SnowparkSQLException
 
 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
@@ -1359,3 +1359,168 @@ def test_save_html(mock_render, snowpark_session):
         compare.report(html_file="test.html")
         assert mock_render.call_count == 4
         m.assert_called_with("test.html", "w")
+
+
+def test_full_join_counts_no_matches(snowpark_session):
+    df1 = snowpark_session.createDataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 3}])
+    df2 = snowpark_session.createDataFrame([{"A": 1, "B": 4}, {"A": 1, "B": 5}])
+    compare = SnowflakeCompare(
+        snowpark_session, df1, df2, ["A", "B"], ignore_spaces=False
+    )
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="A")
+        .toPandas()
+        .sort_values("A")
+        .reset_index(drop=True),
+        pd.DataFrame([{"A": 1}, {"A": 1}, {"A": 1}, {"A": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="B")
+        .toPandas()
+        .sort_values("B")
+        .reset_index(drop=True),
+        pd.DataFrame([{"B": 2}, {"B": 3}, {"B": 4}, {"B": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["A", "B"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [{"A": 1, "B": 2}, {"A": 1, "B": 3}, {"A": 1, "B": 4}, {"A": 1, "B": 5}]
+        ),
+    )
+
+
+def test_full_join_counts_some_matches(snowpark_session):
+    df1 = snowpark_session.createDataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 3}])
+    df2 = snowpark_session.createDataFrame([{"A": 1, "B": 2}, {"A": 1, "B": 5}])
+    compare = SnowflakeCompare(
+        snowpark_session, df1, df2, ["A", "B"], ignore_spaces=False
+    )
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="A")
+        .toPandas()
+        .sort_values("A")
+        .reset_index(drop=True),
+        pd.DataFrame([{"A": 1}, {"A": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="B")
+        .toPandas()
+        .sort_values("B")
+        .reset_index(drop=True),
+        pd.DataFrame([{"B": 3}, {"B": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["A", "B"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"A": 1, "B": 3},
+                {"A": 1, "B": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_no_matches(snowpark_session):
+    df1 = snowpark_session.createDataFrame(
+        [{"A": 1, "B": 2, "C": 4}, {"A": 1, "B": 3, "C": 4}]
+    )
+    df2 = snowpark_session.createDataFrame(
+        [{"A": 1, "B": 4, "D": 5}, {"A": 1, "B": 5, "D": 5}]
+    )
+    compare = SnowflakeCompare(
+        snowpark_session, df1, df2, ["A", "B"], ignore_spaces=False
+    )
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="A")
+        .toPandas()
+        .sort_values("A")
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [{"A": 1}, {"A": 1}, {"A": 1}, {"A": 1}],
+        ),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="B")
+        .toPandas()
+        .sort_values("B")
+        .reset_index(drop=True),
+        pd.DataFrame([{"B": 2}, {"B": 3}, {"B": 4}, {"B": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["A", "B"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"A": 1, "B": 2},
+                {"A": 1, "B": 3},
+                {"A": 1, "B": 4},
+                {"A": 1, "B": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_some_matches(snowpark_session):
+    df1 = snowpark_session.createDataFrame(
+        [{"A": 1, "B": 2, "C": 4}, {"A": 1, "B": 3, "C": 4}]
+    )
+    df2 = snowpark_session.createDataFrame(
+        [{"A": 1, "B": 2, "D": 5}, {"A": 1, "B": 5, "D": 5}]
+    )
+    compare = SnowflakeCompare(
+        snowpark_session, df1, df2, ["A", "B"], ignore_spaces=False
+    )
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="A")
+        .toPandas()
+        .sort_values("A")
+        .reset_index(drop=True),
+        pd.DataFrame([{"A": 1}, {"A": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="B")
+        .toPandas()
+        .sort_values("B")
+        .reset_index(drop=True),
+        pd.DataFrame([{"B": 3}, {"B": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["A", "B"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"A": 1, "B": 3},
+                {"A": 1, "B": 5},
+            ]
+        ),
+    )
diff --git a/tests/test_spark/test_sql_spark.py b/tests/test_spark/test_sql_spark.py
index 65c13c7..ef5106f 100644
--- a/tests/test_spark/test_sql_spark.py
+++ b/tests/test_spark/test_sql_spark.py
@@ -42,7 +42,7 @@ from datacompy.spark.sql import (
     columns_equal,
     temp_column_name,
 )
-from pandas.testing import assert_series_equal
+from pandas.testing import assert_frame_equal, assert_series_equal
 
 logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
 
@@ -1351,3 +1351,159 @@ def test_unicode_columns(spark_session):
     assert compare.matches()
     # Just render the report to make sure it renders.
     compare.report()
+
+
+def test_full_join_counts_no_matches(spark_session):
+    df1 = spark_session.createDataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = spark_session.createDataFrame([{"a": 1, "b": 4}, {"a": 1, "b": 5}])
+    compare = SparkSQLCompare(spark_session, df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a")
+        .toPandas()
+        .sort_values("a")
+        .reset_index(drop=True),
+        pd.DataFrame([{"a": 1}, {"a": 1}, {"a": 1}, {"a": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b")
+        .toPandas()
+        .sort_values("b")
+        .reset_index(drop=True),
+        pd.DataFrame([{"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]),
+        check_like=False,
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["a", "b"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [{"a": 1, "b": 2}, {"a": 1, "b": 3}, {"a": 1, "b": 4}, {"a": 1, "b": 5}]
+        ),
+    )
+
+
+def test_full_join_counts_some_matches(spark_session):
+    df1 = spark_session.createDataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 3}])
+    df2 = spark_session.createDataFrame([{"a": 1, "b": 2}, {"a": 1, "b": 5}])
+    compare = SparkSQLCompare(spark_session, df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a")
+        .toPandas()
+        .sort_values("a")
+        .reset_index(drop=True),
+        pd.DataFrame([{"a": 1}, {"a": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b")
+        .toPandas()
+        .sort_values("b")
+        .reset_index(drop=True),
+        pd.DataFrame([{"b": 3}, {"b": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["a", "b"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_no_matches(spark_session):
+    df1 = spark_session.createDataFrame(
+        [{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}]
+    )
+    df2 = spark_session.createDataFrame(
+        [{"a": 1, "b": 4, "d": 5}, {"a": 1, "b": 5, "d": 5}]
+    )
+    compare = SparkSQLCompare(spark_session, df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert not compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 0
+    assert_frame_equal(
+        compare.sample_mismatch(column="a")
+        .toPandas()
+        .sort_values("a")
+        .reset_index(drop=True),
+        pd.DataFrame([{"a": 1}, {"a": 1}, {"a": 1}, {"a": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b")
+        .toPandas()
+        .sort_values("b")
+        .reset_index(drop=True),
+        pd.DataFrame([{"b": 2}, {"b": 3}, {"b": 4}, {"b": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["a", "b"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"a": 1, "b": 2},
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 4},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )
+
+
+def test_non_full_join_counts_some_matches(spark_session):
+    df1 = spark_session.createDataFrame(
+        [{"a": 1, "b": 2, "c": 4}, {"a": 1, "b": 3, "c": 4}]
+    )
+    df2 = spark_session.createDataFrame(
+        [{"a": 1, "b": 2, "d": 5}, {"a": 1, "b": 5, "d": 5}]
+    )
+    compare = SparkSQLCompare(spark_session, df1, df2, ["a", "b"], ignore_spaces=False)
+    assert not compare.matches()
+    assert not compare.all_columns_match()
+    assert not compare.all_rows_overlap()
+    assert compare.intersect_rows_match()
+    assert compare.count_matching_rows() == 1
+    assert_frame_equal(
+        compare.sample_mismatch(column="a")
+        .toPandas()
+        .sort_values("a")
+        .reset_index(drop=True),
+        pd.DataFrame([{"a": 1}, {"a": 1}]),
+    )
+    assert_frame_equal(
+        compare.sample_mismatch(column="b")
+        .toPandas()
+        .sort_values("b")
+        .reset_index(drop=True),
+        pd.DataFrame([{"b": 3}, {"b": 5}]),
+    )
+    assert_frame_equal(
+        compare.all_mismatch()
+        .toPandas()
+        .sort_values(["a", "b"])
+        .reset_index(drop=True),
+        pd.DataFrame(
+            [
+                {"a": 1, "b": 3},
+                {"a": 1, "b": 5},
+            ]
+        ),
+    )