Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Use Pandas merge(hash join) for equality operator in conditional_join
# Brief Description

Using Pandas Hash Join(`pd.merge`) for equality operator in `conditional_join` should improve performance, compared to binary search, especially for strings.



Discussion:

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -56,9 +56,7 @@
     check,
     check_column,
     deprecated_alias,
-    _conditional_join_preliminary_checks,
     _conditional_join_compute,
-    _cond_join_suffixes,
     _case_when,
 )
 
@@ -6321,23 +6319,19 @@ def conditional_join(
     *conditions,
     how: str = "inner",
     sort_by_appearance: bool = False,
-    suffixes=("_x", "_y"),
 ) -> pd.DataFrame:
     """
 
     This is a convenience function that operates similarly to ``pd.merge``,
-    but allows joins on inequality operators, or a combination of equi
-    and non-equi joins.
+    but allows joins on inequality operators,
+    or a combination of equi and non-equi joins.
 
     If the join is solely on equality, `pd.merge` function
-    is more efficient and should be used instead. Infact,
-    for multiple conditions where equality is involved,
-    a `pd.merge`, followed by filter(via `query` or `loc`)
-    is more efficient. This is even more evident when joining
-    on strings.
+    is more efficient and should be used instead.
+
     If you are interested in nearest joins, or rolling joins,
     `pd.merge_asof` covers that. There is also the IntervalIndex,
-    which can be more efficient for range joins, especially if
+    which is usually more efficient for range joins, especially if
     the intervals do not overlap.
 
     This function returns rows, if any, where values from `df` meet the
@@ -6349,246 +6343,20 @@ def conditional_join(
 
     The operator can be any of `==`, `!=`, `<=`, `<`, `>=`, `>`.
 
-    A binary search is used to get the relevant rows; this avoids
-    a cartesian join, and makes the process less memory intensive.
+    A binary search is used to get the relevant rows for non-equi joins;
+    this avoids a cartesian join, and makes the process less memory intensive.
+
+    For equi-joins, Pandas internal merge function (a hash join) is used.
 
     The join is done only on the columns.
     MultiIndex columns are not supported.
 
-    Only numeric, date and string columns are supported.
-
-    If joining on strings, only the `==` operator is supported.
+    For non-equi joins, onnly numeric and date columns are supported.
 
     Only `inner`, `left`, and `right` joins are supported.
 
 
-    Example :
-
-    df1::
-
-            id  value_1
-        0   1        2
-        1   1        5
-        2   1        7
-        3   2        1
-        4   2        3
-        5   3        4
-
-
-    df2::
-
-            id  value_2A  value_2B
-        0   1         0         1
-        1   1         3         5
-        2   1         7         9
-        3   1        12        15
-        4   2         0         1
-        5   2         2         4
-        6   2         3         6
-        7   3         1         3
-
-    Join on equi and non-equi operators is possible::
-
-        df1.conditional_join(
-                df2,
-                ('id', 'id', '=='),
-                ('value_1', 'value_2A', '>='),
-                ('value_1', 'value_2B', '<='),
-                sort_by_appearance = True
-            )
-
-            id_x  value_1  id_y  value_2A  value_2B
-        0     1        5     1         3         5
-        1     1        7     1         7         9
-        2     2        1     2         0         1
-        3     2        3     2         2         4
-        4     2        3     2         3         6
-
-    The default join is `inner`. left and right joins are supported as well::
-
-        df1.conditional_join(
-                df2,
-                ('id', 'id', '=='),
-                ('value_1', 'value_2A', '>='),
-                ('value_1', 'value_2B', '<='),
-                how='left',
-                sort_by_appearance = True
-            )
-
-            id_x  value_1  id_y  value_2A  value_2B
-        0     1        2   NaN       NaN       NaN
-        1     1        5   1.0       3.0       5.0
-        2     1        7   1.0       7.0       9.0
-        3     2        1   2.0       0.0       1.0
-        4     2        3   2.0       2.0       4.0
-        5     2        3   2.0       3.0       6.0
-        6     3        4   NaN       NaN       NaN
-
-
-        df1.conditional_join(
-                df2,
-                ('id', 'id', '=='),
-                ('value_1', 'value_2A', '>='),
-                ('value_1', 'value_2B', '<='),
-                how='right',
-                sort_by_appearance = True
-            )
-
-            id_x  value_1  id_y  value_2A  value_2B
-        0   NaN      NaN     1         0         1
-        1   1.0      5.0     1         3         5
-        2   1.0      7.0     1         7         9
-        3   NaN      NaN     1        12        15
-        4   2.0      1.0     2         0         1
-        5   2.0      3.0     2         2         4
-        6   2.0      3.0     2         3         6
-        7   NaN      NaN     3         1         3
-
-
-    Join on just the non-equi joins is also possible::
-
-        df1.conditional_join(
-                df2,
-                ('value_1', 'value_2A', '>'),
-                ('value_1', 'value_2B', '<'),
-                how='inner',
-                sort_by_appearance = True
-            )
-
-            id_x  value_1  id_y  value_2A  value_2B
-        0     1        2     3         1         3
-        1     1        5     2         3         6
-        2     2        3     2         2         4
-        3     3        4     1         3         5
-        4     3        4     2         3         6
-
-    The default for the `suffixes` parameter is ``(_x, _y)``,
-    One of the suffixes can be set as ``None``;
-    this avoids a suffix on the columns from the
-    relevant dataframe::
-
-        df1.conditional_join(
-                df2,
-                ('value_1', 'value_2A', '>'),
-                ('value_1', 'value_2B', '<'),
-                how='inner',
-                sort_by_appearance = True,
-                suffixes = (None, '_y')
-            )
-
-            id  value_1  id_y  value_2A  value_2B
-        0   1        2     3         1         3
-        1   1        5     2         3         6
-        2   2        3     2         2         4
-        3   3        4     1         3         5
-        4   3        4     2         3         6
-
-    Join on just equality is also possible, but should be avoided -
-    Pandas merge/join is more efficient::
-
-        df1
-            col_a col_b
-        0      1     A
-        1      2     B
-        2      3     C
-
-        df2
-            col_a col_c
-        0      0     Z
-        1      2     X
-        2      3     Y
-
-        df1.conditional_join(
-                df2,
-                ('col_a', 'col_a', '=='),
-                sort_by_appearance = True
-            )
-
-             col_a_x col_b  col_a_y col_c
-        0        2     B        2     X
-        1        3     C        3     Y
-
-    Join on not equal -> ``!=`` ::
-
-        df1.conditional_join(
-                df2,
-                ('col_a', 'col_a', '!='),
-                sort_by_appearance = True
-            )
-
-             col_a_x col_b  col_a_y col_c
-        0        1     A        0     Z
-        1        1     A        2     X
-        2        1     A        3     Y
-        3        2     B        0     Z
-        4        2     B        3     Y
-        5        3     C        0     Z
-        6        3     C        2     X
-
-
-    If the order from `right` is not important,
-    `sort_by_appearance` can be set to  ``False``
-    (this is the default)::
-
-        df1.conditional_join(
-                df2,
-                ('col_a', 'col_a', '>'),
-                sort_by_appearance = False
-            )
-
-             col_a_x col_b  col_a_y col_c
-        0        1     A        0     Z
-        1        2     B        0     Z
-        2        3     C        0     Z
-        3        3     C        2     X
-
-
-    .. note:: If `df` or `right` has labeled indices,
-              it will be lost after the merge,
-              and replaced with an integer index.
-              If you wish to preserve the labeled indices,
-              you can convert them to columns
-              before running the conditional join.
-
-    .. note:: All the columns from `df` and `right`
-              are returned in the final output.
-
-    .. note:: For multiple condtions, If there are nulls
-              in the join columns, they will not be
-              preserved for `!=` operator. Nulls are only
-              preserved for `!=` operator for single condition.
-
-    Functional usage syntax:
-
-    .. code-block:: python
-
-        import pandas as pd
-        import janitor as jn
-
-        df = pd.DataFrame(...)
-        right = pd.DataFrame(...)
-
-        df = jn.conditional_join(
-                df,
-                right,
-                *conditions,
-                sort_by_appearance = True/False,
-                suffixes = ("_x", "_y"),
-                )
-
-    Method chaining syntax:
-
-    .. code-block:: python
-
-        df = df.conditional_join(
-                right,
-                *conditions,
-                sort_by_appearance = True/False,
-                suffixes = ("_x", "_y"),
-                )
-
-
-    :param df: A Pandas dataframe.
+    :param df: A Pandas DataFrame.
     :param right: Named Series or DataFrame to join to.
     :param conditions: Variable argument of tuple(s) of the form
         ``(left_on, right_on, op)``, where `left_on` is the column
@@ -6600,46 +6368,11 @@ def conditional_join(
         Full join is not supported. Defaults to `inner`.
     :param sort_by_appearance: Default is `False`. If True,
         values from `right` that meet the join condition will be returned
-        in the final dataframe in the same order that they were before the
-        join.
-    :param suffixes: tuple, default is ``(_x, _y)``.
-        A sequence of length 2, where each element is optionally a string,
-        indicating the suffix to add to the overlapping column names
-        in `df` and `right`. Pass a value of ``None``
-        instead of a string to indicate that the  column name
-        from `df` or `right` should be left as-is, with no suffix.
-        At least one of the values must not be ``None``.
+        in the final dataframe in the same order
+        that they were before the join.
     :returns: A pandas DataFrame of the two merged Pandas objects.
-    :raises ValueError: if columns from `df` or `right` is a MultiIndex.
-    :raises ValueError: if condition in *conditions is not a tuple.
-
-    .. # noqa: DAR402
     """
 
-    (
-        df,
-        right,
-        conditions,
-        how,
-        sort_by_appearance,
-        suffixes,
-    ) = _conditional_join_preliminary_checks(
-        df,
-        right,
-        conditions,
-        how,
-        sort_by_appearance,
-        suffixes,
-    )
-
-    df, right, conditions = _cond_join_suffixes(
-        df, right, conditions, suffixes
-    )
-
-    # the numeric indexes play a crucial part in position tracking
-    df.index = np.arange(len(df))
-    right.index = np.arange(len(right))
-
     return _conditional_join_compute(
         df, right, conditions, how, sort_by_appearance
     )
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -26,14 +26,18 @@
 
 import numpy as np
 import pandas as pd
+from pandas.core.reshape.merge import _MergeOperation
+from pandas.core.construction import extract_array
 from pandas.api.types import (
     CategoricalDtype,
     is_extension_array_dtype,
     is_list_like,
     is_scalar,
-    is_numeric_dtype,
+    is_integer_dtype,
+    is_float_dtype,
     is_string_dtype,
     is_datetime64_dtype,
+    is_categorical_dtype,
 )
 from pandas.core.common import apply_if_callable
 from enum import Enum
@@ -2305,6 +2309,7 @@ def _check_operator(op: str):
     """
     Check that operator is one of
     `>`, `>=`, `==`, `!=`, `<`, `<=`.
+
     Used in `conditional_join`.
     """
     sequence_of_operators = {op.value for op in JOINOPERATOR}
@@ -2321,22 +2326,19 @@ def _conditional_join_preliminary_checks(
     df: pd.DataFrame,
     right: Union[pd.DataFrame, pd.Series],
     conditions: tuple,
-    how: str = "inner",
-    sort_by_appearance: bool = False,
-    suffixes=("_x", "_y"),
+    how: str,
+    sort_by_appearance: tuple,
 ) -> tuple:
     """
     Preliminary checks for conditional_join are conducted here.
+
     This function checks for conditions such as
     MultiIndexed dataframe columns,
     improper `suffixes` configuration,
     as well as unnamed Series.
 
     A tuple of
-    (`df`, `right`,
-     `left_on`, `right_on`,
-     `operator`, `sort_by_appearance`,
-     `suffixes`)
+    (`df`, `right`, `left_on`, `right_on`, `operator`)
     is returned.
     """
 
@@ -2392,7 +2394,7 @@ def _conditional_join_preliminary_checks(
             Kindly provide at least one join condition.
             """
         )
-    # each condition should be a tuple of length 3:
+
     for condition in conditions:
         check("condition", condition, [tuple])
         len_condition = len(condition)
@@ -2400,7 +2402,7 @@ def _conditional_join_preliminary_checks(
             raise ValueError(
                 f"""
                 condition should have only three elements.
-                Your condition however is of length {len_condition}
+                {condition} however is of length {len_condition}.
                 """
             )
 
@@ -2420,138 +2422,81 @@ def _conditional_join_preliminary_checks(
 
     check("sort_by_appearance", sort_by_appearance, [bool])
 
-    check("suffixes", suffixes, [tuple])
-
-    if len(suffixes) != 2:
-        raise ValueError("`suffixes` argument should be a 2-length tuple")
-
-    if suffixes == (None, None):
-        raise ValueError("At least one of the suffixes should be non-null.")
-
-    for suffix in suffixes:
-        check("suffix", suffix, [str, type(None)])
-
-    return (df, right, conditions, how, sort_by_appearance, suffixes)
+    return df, right, conditions, how, sort_by_appearance
 
 
-def _cond_join_suffixes(
-    df: pd.DataFrame, right: pd.DataFrame, conditions: tuple, suffixes: tuple
-):
+def _conditional_join_type_check(
+    left_column: pd.Series, right_column: pd.Series, op: str
+) -> None:
     """
-    If there are overlapping columns in `df` and `right`,
-    modify the columns, using the suffix in suffixes.
-    A tuple of (df, right, conditions) is returned.
+    Raise error if column type is not
+    any of numeric or datetime.
     """
 
-    common_columns = df.columns.intersection(right.columns, sort=False)
-
-    left_on, right_on, operators = zip(*conditions)
-    if not common_columns.empty:
-        left_suffix, right_suffix = suffixes
-
-        if left_suffix:
-            mapping = {}
-            for common in common_columns:
-                new_label = f"{common}{left_suffix}"
-                if new_label in df.columns:
-                    raise ValueError(
-                        f"""
-                        {new_label} is present in `df` columns.
-                        Kindly provide a unique suffix to create
-                        columns that are not present in `df`.
-                        """
-                    )
-                mapping[common] = new_label
-            left_on = [
-                f"{label}{left_suffix}" if label in common_columns else label
-                for label in left_on
-            ]
-            df = df.rename(columns=mapping)
-        if right_suffix:
-            mapping = {}
-            for common in common_columns:
-                new_label = f"{common}{right_suffix}"
-                if new_label in right.columns:
-                    raise ValueError(
-                        f"""
-                        {new_label} is present in `right` columns.
-                        Kindly provide a unique suffix to create
-                        columns that are not present in `right`.
-                        """
-                    )
-
-                mapping[common] = new_label
-            right_on = [
-                f"{label}{right_suffix}" if label in common_columns else label
-                for label in right_on
-            ]
-            right = right.rename(columns=mapping)
-
-    conditions = [*zip(left_on, right_on, operators)]
+    # Allow merges on strings/categoricals,
+    # but only on the `==` operator?
+    permitted_types = {
+        is_datetime64_dtype,
+        is_integer_dtype,
+        is_float_dtype,
+        is_string_dtype,
+        is_categorical_dtype,
+    }
+    for func in permitted_types:
+        if func(left_column):
+            break
+    else:
+        raise ValueError(
+            """
+            conditional_join only supports
+            string, category, integer,
+            float or date dtypes.
+            """
+        )
+    cols = (left_column, right_column)
+    for func in permitted_types:
+        if all(map(func, cols)):
+            break
+    else:
+        raise ValueError(
+            f"""
+             Both columns should have the same type.
+             `{left_column.name}` has {left_column.dtype} type;
+             `{right_column.name}` has {right_column.dtype} type.
+             """
+        )
 
-    return df, right, conditions
+    if (
+        is_categorical_dtype(left_column)
+        and op != JOINOPERATOR.STRICTLY_EQUAL.value
+    ):
+        raise ValueError(
+            """
+            For categorical columns,
+            only the `==` operator is supported.
+            """
+        )
 
+    if (
+        is_string_dtype(left_column)
+        and op != JOINOPERATOR.STRICTLY_EQUAL.value
+    ):
+        raise ValueError(
+            """
+            For string columns,
+            only the `==` operator is supported.
+            """
+        )
 
-def _conditional_join_type_check(
-    left_column: pd.Series, right_column: pd.Series, op: str
-) -> None:
-    """
-    Raise error if column type is not any of
-    numeric, datetime, or string.
-    Strings are not supported on non-equi operators.
-    """
-
-    error_msg = """
-          conditional_join only supports
-          numeric, date, or string dtypes.
-          The columns must also be of the same type.
-          """
-    error_msg_string = """
-                       Strings can only be compared
-                       on the equal(`==`) operator.
-                       """
-    error_msg_dtype = """
-                       The left column ({l_name}) is a {dtype} type,
-                       while the right column ({r_name}) is not.
-                       Kindly ensure both columns are the same dtype.
-                       """
-    if is_string_dtype(left_column):
-        if not is_string_dtype(right_column):
-            mapper = {
-                "l_name": left_column.name,
-                "dtype": "string",
-                "r_name": right_column.name,
-            }
-            raise ValueError(error_msg_dtype.format(**mapper))
-        if op != JOINOPERATOR.STRICTLY_EQUAL.value:
-            raise ValueError(error_msg_string)
-        return None
-    if is_numeric_dtype(left_column):
-        if not is_numeric_dtype(right_column):
-            mapper = {
-                "l_name": left_column.name,
-                "dtype": "numeric",
-                "r_name": right_column.name,
-            }
-            raise ValueError(error_msg_dtype.format(**mapper))
-        return None
-    if is_datetime64_dtype(left_column):
-        if not is_datetime64_dtype(right_column):
-            mapper = {
-                "l_name": left_column.name,
-                "dtype": "datetime",
-                "r_name": right_column.name,
-            }
-            raise ValueError(error_msg_dtype.format(**mapper))
-        return None
-    raise ValueError(error_msg)
+    return None
 
 
 def _interval_ranges(indices: np.ndarray, right: np.ndarray) -> np.ndarray:
     """
     Create `range` indices for each value in
-    `right_keys` in `_equal_indices`, `_less_than_indices`,
+    `right_keys` in  `_less_than_indices`
     and `_greater_than_indices`.
+
     It is faster than a list comprehension, especially
     for large arrays.
 
@@ -2562,13 +2507,13 @@ def _interval_ranges(indices: np.ndarray, right: np.ndarray) -> np.ndarray:
     cum_length = cum_length.cumsum()
     # generate ones
     # note that cum_length[-1] is the total
-    # number of inidices to be generated
+    # number of index positions to be generated
     ids = np.ones(cum_length[-1], dtype=int)
     ids[0] = indices[0]
     # at each specific point in id, replace the value
     # so, we should have say 0, 1, 1, 1, 1, -5, 1, 1, 1, -3, ...
     # when a cumsum is implemented in the next line,
-    # we get, 0, 1, 2, 3, 4, 0, 1,2, 3, 0, ...
+    # we get, 0, 1, 2, 3, 4, 0, 1, 2, 3, 0, ...
     # our ranges is obtained, with more efficiency
     # for larger arrays
     ids[cum_length[:-1]] = indices[1:] - right[:-1] + 1
@@ -2579,96 +2524,55 @@ def _interval_ranges(indices: np.ndarray, right: np.ndarray) -> np.ndarray:
 
 
 def _equal_indices(
-    left_c: pd.Series, right_c: pd.Series, len_conditions: int
+    left_c: Union[pd.Series, pd.DataFrame],
+    right_c: Union[pd.Series, pd.DataFrame],
+    len_conditions: int,
 ) -> tuple:
     """
-    Use binary search to get indices where
-    `left_c` is exactly  equal to `right_c`.
+    Use Pandas' merge internal functions
+    to find the matches, if any.
 
     Returns a tuple of (left_c, right_c)
     """
 
-    if right_c.hasnans:
-        right_c = right_c.dropna()
-    if not right_c.is_monotonic_increasing:
-        right_c = right_c.sort_values()
+    if isinstance(left_c, pd.Series):
+        left_on = left_c.name
+        right_on = right_c.name
+    else:
+        left_on = [*left_c.columns]
+        right_on = [*right_c.columns]
+
+    outcome = _MergeOperation(
+        left=left_c,
+        right=right_c,
+        left_on=left_on,
+        right_on=right_on,
+        sort=False,
+    )
 
-    lower_boundary = right_c.searchsorted(left_c, side="left")
-    upper_boundary = right_c.searchsorted(left_c, side="right")
-    keep_rows = lower_boundary < upper_boundary
-    if keep_rows.sum() == 0:  # no match
+    left_index, right_index = outcome._get_join_indexers()
+
+    if not left_index.size > 0:
         return None
-    # keep only matching rows
-    if keep_rows.sum() < keep_rows.size:
-        left_c = left_c[keep_rows]
-        lower_boundary = lower_boundary[keep_rows]
-        upper_boundary = upper_boundary[keep_rows]
+
     if len_conditions > 1:
-        return left_c.index, (upper_boundary - lower_boundary).sum()
-    positions = _interval_ranges(lower_boundary, upper_boundary)
-    left_repeat = upper_boundary - lower_boundary
-    left_c = left_c.index.repeat(left_repeat)
-    right_c = right_c.index.take(positions)
+        return left_index, right_index
 
-    return left_c, right_c
+    return left_c.index[left_index], right_c.index[right_index]
 
 
-def _not_equal_indices(
-    left_c: pd.Series, right_c: pd.Series, len_conditions: int
-) -> tuple:
+def _not_equal_indices(left_c: pd.Series, right_c: pd.Series) -> tuple:
     """
     Use binary search to get indices where
     `left_c` is exactly  not equal to `right_c`.
+
     It is a combination of strictly less than
     and strictly greater than indices.
 
     Returns a tuple of (left_c, right_c)
     """
 
-    dummy = pd.Int64Index([])
-    left_nulls = dummy
-    right_nulls = dummy
-
-    # nulls are not preserved here
-    if len_conditions > 1:
-        outcome = _less_than_indices(left_c, right_c, True, 2)
-
-        if outcome is None:
-            lt_left = dummy
-            lt_counts = 0
-        else:
-            lt_left, lt_counts = outcome
-
-        outcome = _greater_than_indices(left_c, right_c, True, 2)
-
-        if outcome is None:
-            gt_left = dummy
-            gt_counts = 0
-        else:
-            gt_left, gt_counts = outcome
-
-        left_c = lt_left.append(gt_left)
-
-        if left_c.empty:
-            return None
-
-        return left_c, lt_counts + gt_counts
-
-    # capture null positions, since NaN != NaN
-    # if left_c has nulls, I want to capture the positions
-    # and hook it up with the index positions for nulls
-    # in right_c, it it exists
-    base_left = left_c.copy()
-    if right_c.hasnans:
-        nulls = right_c.isna()
-        right_nulls = right_c.index[nulls]
-        right_c = right_c[~nulls]
-    if not right_c.is_monotonic_increasing:
-        right_c = right_c.sort_values()
-    if left_c.hasnans:
-        nulls = left_c.isna()
-        left_nulls = left_c.index[nulls]
-        left_c = left_c[~nulls]
+    dummy = np.array([], dtype=int)
 
     outcome = _less_than_indices(left_c, right_c, True, 1)
 
@@ -2686,23 +2590,10 @@ def _not_equal_indices(
     else:
         gt_left, gt_right = outcome
 
-    nulls_left = dummy
-    nulls_right = dummy
-    if left_nulls.empty is False:
-        # repeat right index, tile left_nulls to ensure match
-        nulls_right = right_c.index.repeat(left_nulls.size)
-        left_nulls = np.tile(left_nulls, right_c.size)
-        left_nulls = pd.Index(left_nulls)
-    if right_nulls.empty is False:
-        # repeat left index, tile right nulls
-        # base_left is used here, to capture index for nulls,
-        # if present
-        nulls_left = base_left.index.repeat(right_nulls.size)
-        right_nulls = np.tile(right_nulls, base_left.size)
-        right_nulls = pd.Index(right_nulls)
-
-    left_c = lt_left.append([gt_left, left_nulls, nulls_left])
-    right_c = lt_right.append([gt_right, nulls_right, right_nulls])
+    if (not lt_left.size > 0) and (not gt_left.size > 0):
+        return None
+    left_c = np.concatenate([lt_left, gt_left])
+    right_c = np.concatenate([lt_right, gt_right])
 
     return left_c, right_c
 
@@ -2713,6 +2604,7 @@ def _less_than_indices(
     """
     Use binary search to get indices where left_c
     is less than or equal to right_c.
+
     If strict is True,then only indices
     where `left_c` is less than
     (but not equal to) `right_c` are returned.
@@ -2728,6 +2620,12 @@ def _less_than_indices(
         right_c = right_c.dropna()
     if not right_c.is_monotonic_increasing:
         right_c = right_c.sort_values()
+    if left_c.hasnans:
+        left_c = left_c.dropna()
+    left_index = left_c.index.to_numpy(dtype=int)
+    left_c = extract_array(left_c, extract_numpy=True)
+    right_index = right_c.index.to_numpy(dtype=int)
+    right_c = extract_array(right_c, extract_numpy=True)
 
     search_indices = right_c.searchsorted(left_c, side="left")
     # if any of the positions in `search_indices`
@@ -2737,9 +2635,12 @@ def _less_than_indices(
     # or equal, and should therefore be discarded
     len_right = right_c.size
     rows_equal = search_indices == len_right
+
     if rows_equal.any():
         left_c = left_c[~rows_equal]
+        left_index = left_index[~rows_equal]
         search_indices = search_indices[~rows_equal]
+
     if search_indices.size == 0:
         return None
 
@@ -2747,13 +2648,13 @@ def _less_than_indices(
     # shift upwards to the immediate next position
     # that is not equal
     if strict:
-        rows_equal = right_c.take(search_indices).array
-        rows_equal = left_c.array == rows_equal
+        rows_equal = right_c[search_indices]
+        rows_equal = left_c == rows_equal
         # replace positions where rows are equal
         # with positions from searchsorted('right')
         # positions from searchsorted('right') will never
         # be equal and will be the furthermost in terms of position
-        # example : right_c -> [2, 2,2,3], and we need
+        # example : right_c -> [2, 2, 2, 3], and we need
         # positions where values are not equal for 2;
         # the furthermost will be 3, and searchsorted('right')
         # will return position 3.
@@ -2766,8 +2667,10 @@ def _less_than_indices(
         # have become equal to length of right_c
         # and get rid of them
         rows_equal = search_indices == len_right
+
         if rows_equal.any():
             left_c = left_c[~rows_equal]
+            left_index = left_index[~rows_equal]
             search_indices = search_indices[~rows_equal]
 
     if search_indices.size == 0:
@@ -2776,13 +2679,13 @@ def _less_than_indices(
     indices = np.repeat(len_right, search_indices.size)
 
     if len_conditions > 1:
-        return left_c.index, (indices - search_indices).sum()
+        return (left_index, right_index, search_indices, indices)
 
     positions = _interval_ranges(search_indices, indices)
     search_indices = indices - search_indices
 
-    right_c = right_c.index.take(positions)
-    left_c = left_c.index.repeat(search_indices)
+    right_c = right_index[positions]
+    left_c = left_index.repeat(search_indices)
     return left_c, right_c
 
 
@@ -2792,12 +2695,12 @@ def _greater_than_indices(
     """
     Use binary search to get indices where left_c
     is greater than or equal to right_c.
+
     If strict is True,then only indices
     where `left_c` is greater than
     (but not equal to) `right_c` are returned.
 
     Returns a tuple of (left_c, right_c).
-    Nulls are discarded, even when the operator is `!=`.
     """
 
     # quick break, avoiding the hassle
@@ -2810,15 +2713,20 @@ def _greater_than_indices(
         right_c = right_c.sort_values()
     if left_c.hasnans:
         left_c = left_c.dropna()
+    left_index = left_c.index.to_numpy(dtype=int)
+    left_c = extract_array(left_c, extract_numpy=True)
+    right_index = right_c.index.to_numpy(dtype=int)
+    right_c = extract_array(right_c, extract_numpy=True)
 
     search_indices = right_c.searchsorted(left_c, side="right")
     # if any of the positions in `search_indices`
-    # is equal to 0 (less than 1)
+    # is equal to 0 (less than 1), it implies that
     # left_c[position] is not greater than any value
     # in right_c
     rows_equal = search_indices < 1
     if rows_equal.any():
         left_c = left_c[~rows_equal]
+        left_index = left_index[~rows_equal]
         search_indices = search_indices[~rows_equal]
     if search_indices.size == 0:
         return None
@@ -2827,8 +2735,8 @@ def _greater_than_indices(
     # shift downwards to the immediate next position
     # that is not equal
     if strict:
-        rows_equal = right_c.take(search_indices - 1).array
-        rows_equal = left_c.array == rows_equal
+        rows_equal = right_c[search_indices - 1]
+        rows_equal = left_c == rows_equal
         # replace positions where rows are equal with
         # searchsorted('left');
         # however there can be scenarios where positions
@@ -2848,207 +2756,23 @@ def _greater_than_indices(
         rows_equal = search_indices < 1
         if rows_equal.any():
             left_c = left_c[~rows_equal]
+            left_index = left_index[~rows_equal]
             search_indices = search_indices[~rows_equal]
 
     if search_indices.size == 0:
         return None
 
-    indices = np.repeat(0, search_indices.size)
+    indices = np.zeros(search_indices.size, dtype=np.int8)
 
     if len_conditions > 1:
-        return left_c.index, search_indices.sum()
+        return (left_index, right_index, search_indices, indices)
 
     positions = _interval_ranges(indices, search_indices)
-    right_c = right_c.index.take(positions)
-    left_c = left_c.index.repeat(search_indices)
+    right_c = right_index[positions]
+    left_c = left_index.repeat(search_indices)
     return left_c, right_c
 
 
-operator_map = {
-    JOINOPERATOR.STRICTLY_EQUAL.value: operator.eq,
-    JOINOPERATOR.LESS_THAN.value: operator.lt,
-    JOINOPERATOR.LESS_THAN_OR_EQUAL.value: operator.le,
-    JOINOPERATOR.GREATER_THAN.value: operator.gt,
-    JOINOPERATOR.GREATER_THAN_OR_EQUAL.value: operator.ge,
-    JOINOPERATOR.NOT_EQUAL.value: operator.ne,
-}
-
-
-def _multiple_conditional_join(
-    df: pd.DataFrame, right: pd.DataFrame, conditions: list
-) -> tuple:
-    """
-    Use binary search to get indices for paired conditions.
-
-    Returns a tuple of (left_c, right_c)
-    """
-    left_columns, right_columns, _ = zip(*conditions)
-    right_columns = pd.unique(right_columns)
-    right_columns = [*right_columns]
-    left_columns = pd.unique(left_columns)
-    left_columns = [*left_columns]
-
-    df = df.loc[:, left_columns]
-    right = right.loc[:, right_columns]
-
-    if right.isna().any(axis=None):
-        right = right.dropna()
-    if right.empty:
-        return None
-    if df.isna().any(axis=None):
-        df = df.dropna()
-    if df.empty:
-        return None
-
-    # find condition with least number of search points
-    # the lower the number of matching indices from left_c
-    # the better
-    base_index = df.index
-    base_condition = conditions[0]
-    difference = None
-    for condition in conditions:
-        left_on, right_on, op = condition
-        left_c = df[left_on]
-        right_c = right[right_on]
-        result = _generic_func_cond_join(left_c, right_c, op, 2)
-        if result is None:
-            return None
-
-        indexer, indices_count = result
-        if base_index.size > indexer.size:
-            base_index = indexer
-            base_condition = condition
-            difference = indices_count
-        else:
-            if difference is None:
-                difference = indices_count
-            else:
-                # the smaller the indices_count
-                # the better, as this implies
-                # there is a lower number of search points
-                # for that particular condition
-                if difference > indices_count:
-                    base_condition = condition
-                    difference = indices_count
-
-    df = df.loc[base_index]
-    df_mapping = None
-
-    # 25% duplicate check is just a whim
-    # no statistical backing
-    # the idea here is that the less number of searches
-    # the better; after the search we can then
-    # retroactively `blow` the dataframe up to match
-    # the indices of the original dataframe
-    if df.duplicated().mean() > 0.25:
-        df_grouped = df.groupby(left_columns)
-        df_mapping = df_grouped.groups
-        df_unique = pd.DataFrame(df_mapping.keys(), columns=left_columns)
-    else:
-        df_unique = df.copy()
-    right_mapping = None
-    if right.duplicated().mean() > 0.25:
-        right_grouped = right.groupby(right_columns)
-        right_mapping = right_grouped.groups
-        right_unique = pd.DataFrame(
-            right_mapping.keys(), columns=right_columns
-        )
-    else:
-        right_unique = right.copy()
-
-    conditions = [
-        condition for condition in conditions if condition != base_condition
-    ]
-
-    # get the starting indices
-    # we'll take these indices,
-    # iterate through the rest of the conditions
-    # and index df_unique and right_unique
-    # with the booleans to get the final matching rows
-    left_on, right_on, op = base_condition
-    left_c = df_unique[left_on]
-    right_c = right_unique[right_on]
-    result = _generic_func_cond_join(left_c, right_c, op, 1)
-    if result is None:
-        return None
-    left_index, right_index = result
-
-    # iterate through the remaining conditions
-    # to get matching indices
-    for condition in conditions:
-        left_on, right_on, op = condition
-        left_c = df_unique.loc[left_index, left_on].array
-        right_c = right_unique.loc[right_index, right_on].array
-        op = operator_map[op]
-        boolean_array = op(left_c, right_c)
-        if not boolean_array.any():
-            return None
-        if boolean_array.all():
-            continue
-        left_index = left_index[boolean_array]
-        right_index = right_index[boolean_array]
-
-    index_left = None
-    index_right = None
-    # here we blow up the dataframe to match the original size
-    # for duplicated dataframes
-    if df_mapping:
-        mapper = (series for _, series in df_unique.items())
-        mapper = zip(*mapper)
-        if df.columns.size == 1:
-            mapper = [ent[0] for ent in mapper]
-        mapper = dict(zip(mapper, df_unique.index))
-        # align df_unique's index with all the indices
-        # from the original dataframe
-        df_mapping = {mapper[ent]: value for ent, value in df_mapping.items()}
-        # use left_index_map if right is duplicated as well
-        left_index_map = left_index.map(df_mapping)
-        index_left = np.concatenate(left_index_map)
-        repeater = []
-        # this takes care of duplicates in left_index as well
-        for key in left_index:
-            value = df_mapping[key]
-            repeater.append(value.size)
-        index_right = right_index.repeat(repeater)
-
-    if right_mapping:
-        mapper = (series for _, series in right_unique.items())
-        mapper = zip(*mapper)
-        if right.columns.size == 1:
-            # takes care of tuples with just one entry
-            # if not taken care of, it returns a KeyError
-            mapper = [ent[0] for ent in mapper]
-        mapper = dict(zip(mapper, right_unique.index))
-        right_mapping = {
-            mapper[ent]: value for ent, value in right_mapping.items()
-        }
-        if index_right is not None:
-            index_right = index_right.map(right_mapping)
-        else:
-            index_right = right_index.map(right_mapping)
-        index_right = np.concatenate(index_right)
-        repeater = []
-        # takes care of duplicates in right_index as well
-        for key in right_index:
-            value = right_mapping[key]
-            repeater.append(value.size)
-
-        if index_left is None:
-            index_left = left_index.repeat(repeater)
-        else:
-            # allows us to keep the alignment between
-            # left and right
-            index_left = zip(left_index_map, repeater)
-            index_left = [np.repeat(ent, rep) for ent, rep in index_left]
-            index_left = np.concatenate(index_left)
-
-    if df_mapping or right_mapping:
-        left_index = index_left
-        right_index = index_right
-
-    return left_index, right_index
-
-
 def _create_conditional_join_empty_frame(
     df: pd.DataFrame, right: pd.DataFrame, how: str
 ):
@@ -3057,6 +2781,9 @@ def _create_conditional_join_empty_frame(
     if there are no matches.
     """
 
+    df.columns = pd.MultiIndex.from_product([["left"], df.columns])
+    right.columns = pd.MultiIndex.from_product([["right"], right.columns])
+
     if how == JOINTYPES.INNER.value:
         df = df.dtypes.to_dict()
         right = right.dtypes.to_dict()
@@ -3102,22 +2829,25 @@ def _create_conditional_join_frame(
     if sort_by_appearance:
         sorter = np.lexsort((right_index, left_index))
         right_index = right_index[sorter]
-        left_index = left_index.take(sorter)
+        left_index = left_index[sorter]
+
+    df.columns = pd.MultiIndex.from_product([["left"], df.columns])
+    right.columns = pd.MultiIndex.from_product([["right"], right.columns])
 
     if how == JOINTYPES.INNER.value:
-        df = df.reindex(left_index)
-        right = right.reindex(right_index)
-        df.index = np.arange(left_index.size)
+        df = df.loc[left_index]
+        right = right.loc[right_index]
+        df.index = pd.RangeIndex(start=0, stop=left_index.size)
         right.index = df.index
         return pd.concat([df, right], axis="columns", join=how, sort=False)
 
     if how == JOINTYPES.LEFT.value:
-        right = right.reindex(right_index)
+        right = right.loc[right_index]
         right.index = left_index
         return df.join(right, how=how, sort=False).reset_index(drop=True)
 
     if how == JOINTYPES.RIGHT.value:
-        df = df.reindex(left_index)
+        df = df.loc[left_index]
         df.index = right_index
         return df.join(right, how=how, sort=False).reset_index(drop=True)
 
@@ -3153,10 +2883,10 @@ def _generic_func_cond_join(
         return _less_than_indices(left_c, right_c, strict, len_conditions)
     elif op in greater_than_join_types:
         return _greater_than_indices(left_c, right_c, strict, len_conditions)
-    elif op == JOINOPERATOR.STRICTLY_EQUAL.value:
-        return _equal_indices(left_c, right_c, len_conditions)
     elif op == JOINOPERATOR.NOT_EQUAL.value:
-        return _not_equal_indices(left_c, right_c, len_conditions)
+        return _not_equal_indices(left_c, right_c)
+    else:
+        return _equal_indices(left_c, right_c, len_conditions)
 
 
 def _conditional_join_compute(
@@ -3167,21 +2897,51 @@ def _conditional_join_compute(
     sort_by_appearance: bool,
 ) -> pd.DataFrame:
     """
-    This is where the actual computation for the conditional join takes place.
-    If there are no matches, None is returned; if however, there is a match,
-    then a pandas DataFrame is returned.
+    This is where the actual computation
+    for the conditional join takes place.
+    A pandas DataFrame is returned.
     """
 
-    len_conditions = len(conditions)
+    (
+        df,
+        right,
+        conditions,
+        how,
+        sort_by_appearance,
+    ) = _conditional_join_preliminary_checks(
+        df, right, conditions, how, sort_by_appearance
+    )
 
-    if len_conditions == 1:
-        left_on, right_on, op = conditions[0]
+    eq_check = False
+    less_great = False
+    less_greater_types = less_than_join_types.union(greater_than_join_types)
 
+    for condition in conditions:
+        left_on, right_on, op = condition
         left_c = df[left_on]
         right_c = right[right_on]
 
         _conditional_join_type_check(left_c, right_c, op)
 
+        if op == JOINOPERATOR.STRICTLY_EQUAL.value:
+            eq_check = True
+        elif op in less_greater_types:
+            less_great = True
+
+    df.index = pd.RangeIndex(start=0, stop=len(df))
+    right.index = pd.RangeIndex(start=0, stop=len(right))
+
+    if len(conditions) == 1:
+        left_on, right_on, op = conditions[0]
+
+        left_c = df[left_on]
+        right_c = right[right_on]
+
+        if eq_check & left_c.hasnans:
+            left_c = left_c.dropna()
+        if eq_check & right_c.hasnans:
+            right_c = right_c.dropna()
+
         result = _generic_func_cond_join(left_c, right_c, op, 1)
 
         if result is None:
@@ -3193,22 +2953,277 @@ def _conditional_join_compute(
             df, right, left_c, right_c, how, sort_by_appearance
         )
 
-    for condition in conditions:
-        left_on, right_on, op = condition
-        left_c = df[left_on]
-        right_c = right[right_on]
-
-        _conditional_join_type_check(left_c, right_c, op)
+    # multiple conditions
+    if eq_check:
+        result = _multiple_conditional_join_eq(df, right, conditions)
+    elif less_great:
+        result = _multiple_conditional_join_le_lt(df, right, conditions)
+    else:
+        result = _multiple_conditional_join_ne(df, right, conditions)
 
-    result = _multiple_conditional_join(df, right, conditions)
     if result is None:
         return _create_conditional_join_empty_frame(df, right, how)
+
     left_c, right_c = result
+
     return _create_conditional_join_frame(
         df, right, left_c, right_c, how, sort_by_appearance
     )
 
 
+operator_map = {
+    JOINOPERATOR.STRICTLY_EQUAL.value: operator.eq,
+    JOINOPERATOR.LESS_THAN.value: operator.lt,
+    JOINOPERATOR.LESS_THAN_OR_EQUAL.value: operator.le,
+    JOINOPERATOR.GREATER_THAN.value: operator.gt,
+    JOINOPERATOR.GREATER_THAN_OR_EQUAL.value: operator.ge,
+    JOINOPERATOR.NOT_EQUAL.value: operator.ne,
+}
+
+
+def _multiple_conditional_join_eq(
+    df: pd.DataFrame, right: pd.DataFrame, conditions: list
+) -> tuple:
+    """
+    Get indices for multiple conditions,
+    if any of the conditions has an `==` operator.
+
+    Returns a tuple of (df_index, right_index)
+    """
+
+    eq_cond = [
+        cond
+        for cond in conditions
+        if cond[-1] == JOINOPERATOR.STRICTLY_EQUAL.value
+    ]
+    rest = [
+        cond
+        for cond in conditions
+        if cond[-1] != JOINOPERATOR.STRICTLY_EQUAL.value
+    ]
+
+    # get rid of nulls, if any
+    if len(eq_cond) == 1:
+        left_on, right_on, _ = eq_cond[0]
+        left_c = df.loc[:, left_on]
+        right_c = right.loc[:, right_on]
+
+        if left_c.hasnans:
+            left_c = left_c.dropna()
+            df = df.loc[left_c.index]
+
+        if right_c.hasnans:
+            right_c = right_c.dropna()
+            right = right.loc[right_c.index]
+
+    else:
+        left_on, right_on, _ = zip(*eq_cond)
+        left_c = df.loc[:, [*left_on]]
+        right_c = right.loc[:, [*right_on]]
+
+        if left_c.isna().any(axis=None):
+            left_c = left_c.dropna()
+            df = df.loc[left_c.index]
+
+        if right_c.isna().any(axis=None):
+            right_c = right_c.dropna()
+            right = right.loc[right_c.index]
+
+    # get join indices
+    # these are positional, not label indices
+    result = _generic_func_cond_join(
+        left_c, right_c, JOINOPERATOR.STRICTLY_EQUAL.value, 2
+    )
+
+    if result is None:
+        return None
+
+    df_index, right_index = result
+
+    if not rest:
+        return df.index[df_index], right.index[right_index]
+
+    # non-equi conditions are present
+    mask = None
+    for left_on, right_on, op in rest:
+        left_c = extract_array(df[left_on], extract_numpy=True)
+        left_c = left_c[df_index]
+        right_c = extract_array(right[right_on], extract_numpy=True)
+        right_c = right_c[right_index]
+
+        op = operator_map[op]
+        if mask is None:
+            mask = op(left_c, right_c)
+        else:
+            mask &= op(left_c, right_c)
+
+    if not mask.any():
+        return None
+
+    df_index = df_index[mask]
+    right_index = right_index[mask]
+
+    return df.index[df_index], right.index[right_index]
+
+
+def _multiple_conditional_join_ne(
+    df: pd.DataFrame, right: pd.DataFrame, conditions: list
+) -> tuple:
+    """
+    Get indices for multiple conditions,
+    where all the operators are `!=`.
+
+    Returns a tuple of (df_index, right_index)
+    """
+
+    first, *rest = conditions
+    left_on, right_on, op = first
+    left_c = df[left_on]
+    right_c = right[right_on]
+    result = _generic_func_cond_join(left_c, right_c, op, 1)
+    if result is None:
+        return None
+
+    df_index, right_index = result
+
+    mask = None
+    for left_on, right_on, op in rest:
+        left_c = df.loc[df_index, left_on]
+        left_c = extract_array(left_c, extract_numpy=True)
+        right_c = right.loc[right_index, right_on]
+        right_c = extract_array(right_c, extract_numpy=True)
+        op = operator_map[op]
+
+        if mask is None:
+            mask = op(left_c, right_c)
+        else:
+            mask &= op(left_c, right_c)
+
+    if not mask.any():
+        return None
+    return df_index[mask], right_index[mask]
+
+
+def _multiple_conditional_join_le_lt(
+    df: pd.DataFrame, right: pd.DataFrame, conditions: list
+) -> tuple:
+    """
+    Get indices for multiple conditions,
+    if there is no `==` operator, and there is
+    at least one `<`, `<=`, `>`, or `>=` operator.
+
+    Returns a tuple of (df_index, right_index)
+    """
+
+    # find minimum df_index and right_index
+    # aim is to reduce search space
+    df_index = df.index
+    right_index = right.index
+    lt_gt = None
+    less_greater_types = less_than_join_types.union(greater_than_join_types)
+    for left_on, right_on, op in conditions:
+        if op in less_greater_types:
+            lt_gt = left_on, right_on, op
+        # no point checking for `!=`, since best case scenario
+        # they'll have the same no of rows for the less/greater operators
+        elif op == JOINOPERATOR.NOT_EQUAL.value:
+            continue
+
+        left_c = df.loc[df_index, left_on]
+        right_c = right.loc[right_index, right_on]
+
+        result = _generic_func_cond_join(left_c, right_c, op, 2)
+
+        if result is None:
+            return None
+
+        df_index, right_index, *_ = result
+
+    # move le,lt,ge,gt to the fore
+    # less rows to search, compared to !=
+    if conditions[0][-1] not in less_greater_types:
+        conditions = [*conditions]
+        conditions.remove(lt_gt)
+        conditions = [lt_gt] + conditions
+
+    first, *rest = conditions
+    left_on, right_on, op = first
+    left_c = df.loc[df_index, left_on]
+    right_c = right.loc[right_index, right_on]
+
+    result = _generic_func_cond_join(left_c, right_c, op, 2)
+
+    if result is None:
+        return None
+
+    df_index, right_index, search_indices, indices = result
+    if op in less_than_join_types:
+        low, high = search_indices, indices
+    else:
+        low, high = indices, search_indices
+
+    first, *rest = rest
+    left_on, right_on, op = first
+    left_c = df.loc[df_index, left_on]
+    left_c = extract_array(left_c, extract_numpy=True)
+    right_c = right.loc[right_index, right_on]
+    right_c = extract_array(right_c, extract_numpy=True)
+    op = operator_map[op]
+    index_df = []
+    repeater = []
+    index_right = []
+    # offers a bit of a speed up, compared to broadcasting
+    # we go through each search space, and keep only matching rows
+    # constrained to just one loop;
+    # if the join conditions are limited to two, this is helpful;
+    # for more than two, then broadcasting kicks in after this step
+    # running this within numba should offer more speed
+    for indx, val, lo, hi in zip(df_index, left_c, low, high):
+        search = right_c[lo:hi]
+        indexer = right_index[lo:hi]
+        mask = op(val, search)
+        if not mask.any():
+            continue
+        # pandas boolean arrays do not play well with numpy
+        # hence the conversion
+        if is_extension_array_dtype(mask):
+            mask = mask.to_numpy(dtype=bool, na_value=False)
+        indexer = indexer[mask]
+        index_df.append(indx)
+        index_right.append(indexer)
+        repeater.append(indexer.size)
+
+    if not index_df:
+        return None
+
+    df_index = np.repeat(index_df, repeater)
+    right_index = np.concatenate(index_right)
+
+    if not rest:
+        return df_index, right_index
+
+    # blow it up
+    mask = None
+    for left_on, right_on, op in rest:
+        left_c = df.loc[df_index, left_on]
+        left_c = extract_array(left_c, extract_numpy=True)
+        right_c = right.loc[right_index, right_on]
+        right_c = extract_array(right_c, extract_numpy=True)
+        op = operator_map[op]
+
+        if mask is None:
+            mask = op(left_c, right_c)
+        else:
+            mask &= op(left_c, right_c)
+
+    if not mask.any():
+        return None
+    if is_extension_array_dtype(mask):
+        mask = mask.to_numpy(dtype=bool, na_value=False)
+
+    return df_index[mask], right_index[mask]
+
+
 def _case_when_checks(df: pd.DataFrame, args, column_name):
     """
     Preliminary checks on the case_when function.


Test patch:
diff --git a/janitor/testing_utils/strategies.py b/janitor/testing_utils/strategies.py
--- a/janitor/testing_utils/strategies.py
+++ b/janitor/testing_utils/strategies.py
@@ -80,13 +80,14 @@ def conditional_df():
             column(name="C", elements=st.text(max_size=20)),
             column(name="D", dtype=bool),
             column(name="E", dtype="datetime64[ns]"),
-        ]
+        ],
+        index=range_indexes(min_size=1, max_size=10),
     )
 
 
 def conditional_series():
     """Series used in tests_conditional_join"""
-    return series(dtype=int)
+    return series(dtype=int, index=range_indexes(min_size=1, max_size=20))
 
 
 def conditional_right():
@@ -100,5 +101,6 @@ def conditional_right():
             column(name="Booleans", dtype=np.bool_),
             column(name="Dates", dtype="datetime64[ns]"),
             column(name="Dates_Right", dtype="datetime64[ns]"),
-        ]
+        ],
+        index=range_indexes(min_size=1, max_size=10),
     )
diff --git a/tests/functions/test_conditional_join.py b/tests/functions/test_conditional_join.py
--- a/tests/functions/test_conditional_join.py
+++ b/tests/functions/test_conditional_join.py
@@ -13,7 +13,15 @@
 @given(s=conditional_series())
 def test_df_empty(s):
     """Raise ValueError if `df` is empty."""
-    df = pd.DataFrame([], dtype="object")
+    df = pd.DataFrame([], dtype="int", columns=["A"])
+    with pytest.raises(ValueError):
+        df.conditional_join(s, ("A", "non", "=="))
+
+
+@given(df=conditional_df())
+def test_right_empty(df):
+    """Raise ValueError if `right` is empty."""
+    s = pd.Series([], dtype="int", name="A")
     with pytest.raises(ValueError):
         df.conditional_join(s, ("A", "non", "=="))
 
@@ -148,7 +156,7 @@ def test_check_column_exists_right(df, s):
 def test_check_op_correct(df, s):
     """
     Raise ValueError if `op` is not any of
-    `==`, `!=`, `<`, `>`, `>=`, `<=`.
+     `!=`, `<`, `>`, `>=`, `<=`.
     """
 
     with pytest.raises(ValueError):
@@ -172,8 +180,8 @@ def test_check_how_type(df, s):
 @given(df=conditional_df(), s=conditional_series())
 def test_check_how_value(df, s):
     """
-    Raise ValueError if `how` is not one of `inner`, `outer`,
-    `left`, or `right`.
+    Raise ValueError if `how` is not one of
+    `inner`, `left`, or `right`.
     """
 
     with pytest.raises(ValueError):
@@ -181,99 +189,26 @@ def test_check_how_value(df, s):
         df.conditional_join(s, ("B", "B", "<"), how="INNER")
 
 
-@given(df=conditional_df(), s=conditional_series())
-def test_check_sort_by_appearance_type(df, s):
-    """
-    Raise TypeError if `sort_by_appearance` is not a boolean.
-    """
-
-    assume(not df.empty)
-    assume(not s.empty)
-    with pytest.raises(TypeError):
-        s.name = "B"
-        df.conditional_join(s, ("B", "B", "<"), sort_by_appearance="True")
-
-
-@given(df=conditional_df(), s=conditional_series())
-def test_check_suffixes_type(df, s):
-    """
-    Raise TypeError if `suffixes` is not a tuple.
-    """
-
-    assume(not df.empty)
-    assume(not s.empty)
-    with pytest.raises(TypeError):
-        s.name = "B"
-        df.conditional_join(s, ("B", "B", "<"), suffixes=["_x", "_y"])
-
-
-@given(df=conditional_df(), s=conditional_series())
-def test_check_suffixes_length(df, s):
-    """
-    Raise ValueError if `suffixes` is not a tuple of length 2.
-    """
-    with pytest.raises(ValueError):
-        s.name = "B"
-        df.conditional_join(s, ("B", "B", "<"), suffixes=("_x",))
-        df.conditional_join(s, ("B", "B", "<"), suffixes=("_x", "_y", None))
-
-
-@given(df=conditional_df(), s=conditional_series())
-def test_check_suffixes_None(df, s):
+@given(df=conditional_df(), right=conditional_right())
+def test_dtype_strings_non_equi(df, right):
     """
-    Raise ValueError if `suffixes` is (None, None).
+    Raise ValueError if the dtypes are both strings
+    on a non-equi operator.
     """
     with pytest.raises(ValueError):
-        s.name = "B"
-        df.conditional_join(s, ("B", "B", "<"), suffixes=(None, None))
+        df.conditional_join(right, ("C", "Strings", "<"))
 
 
 @given(df=conditional_df(), s=conditional_series())
-def test_check_suffixes_subtype(df, s):
-    """
-    Raise TypeError if any entry `suffixes`
-    is not either None or a string type.
-    """
-
-    assume(not df.empty)
-    assume(not s.empty)
-    with pytest.raises(TypeError):
-        s.name = "B"
-        df.conditional_join(s, ("B", "B", "<"), suffixes=(1, None))
-
-
-@given(df=conditional_df())
-def test_check_suffixes_exists_df(df):
-    """
-    Raise ValueError if suffix already exists in `df`.
-    """
-    with pytest.raises(ValueError):
-        right = df.copy()
-        df.columns = ["A", "A_x", "B", "F", "H"]
-        df.conditional_join(right, ("A", "A", "<"), suffixes=("_x", "_y"))
-
-
-@given(df=conditional_df())
-def test_check_suffixes_exists_right(df):
-    """
-    Raise ValueError if suffix already exists in `right`.
-    """
-    with pytest.raises(ValueError):
-        right = df.copy()
-        right.columns = ["A", "B", "B_y", "C", "E"]
-        df.conditional_join(right, ("B", "B", "<"), suffixes=("_x", "_y"))
-
-
-@given(df=conditional_df(), right=conditional_right())
-def test_dtype_strings_non_equi(df, right):
+def test_dtype_not_permitted(df, s):
     """
-    Raise ValueError if the dtypes are both strings,
-    and the join operator is not the equal operator.
+    Raise ValueError if dtype of column in `df`
+    is not an acceptable type.
     """
+    df["F"] = pd.Timedelta("1 days")
     with pytest.raises(ValueError):
-        df.conditional_join(
-            right, ("C", "Strings", "<"), suffixes=("_x", "_y")
-        )
+        s.name = "A"
+        df.conditional_join(s, ("F", "A", "<"))
 
 
 @given(df=conditional_df(), s=conditional_series())
@@ -284,392 +219,470 @@ def test_dtype_str(df, s):
     """
     with pytest.raises(ValueError):
         s.name = "A"
-        df.conditional_join(s, ("C", "A", "<"), suffixes=("_x", "_y"))
+        df.conditional_join(s, ("C", "A", "<"))
 
 
 @given(df=conditional_df(), s=conditional_series())
-def test_dtype_not_string_datetime_numeric(df, s):
+def test_dtype_category_non_equi(df, s):
     """
-    Raise ValueError if the dtype of column in `df`
-    is not a string, numeric, or datetime dtype.
+    Raise ValueError if dtype is category,
+    and op is non-equi.
     """
     with pytest.raises(ValueError):
         s.name = "A"
+        s = s.astype("category")
         df["C"] = df["C"].astype("category")
-        df.conditional_join(s, ("C", "A", "<"), suffixes=("_x", "_y"))
+        df.conditional_join(s, ("C", "A", "<"))
 
 
-@given(df=conditional_df(), s=conditional_right())
-def test_dtype_int(df, s):
+@given(df=conditional_df(), s=conditional_series())
+def test_check_sort_by_appearance_type(df, s):
     """
-    Raise ValueError if dtype of column in `df`
-    does not match the dtype of column from `right`.
+    Raise TypeError if `sort_by_appearance` is not a boolean.
     """
-    with pytest.raises(ValueError):
-        df.conditional_join(s, ("A", "Strings", "<"), suffixes=("_x", "_y"))
 
-
-@given(df=conditional_df(), s=conditional_right())
-def test_dtype_dates(df, s):
-    """
-    Raise ValueError if dtype of column in `df`
-    does not match the dtype of column from `right`.
-    """
-    with pytest.raises(ValueError):
-        df.conditional_join(s, ("E", "Integers", "<"), suffixes=("_x", "_y"))
+    assume(not df.empty)
+    assume(not s.empty)
+    with pytest.raises(TypeError):
+        s.name = "B"
+        df.conditional_join(s, ("B", "B", "<"), sort_by_appearance="True")
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_equality_numeric(df, right):
-    """Test output for a single condition. "=="."""
+def test_single_condition_less_than_floats(df, right):
+    """Test output for a single condition. "<"."""
     assume(not df.empty)
     assume(not right.empty)
-    # simulate output as it would be in SQL
-    left_on, right_on = ["B", "Integers"]
+    left_on, right_on = ["B", "Numeric"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} == {right_on}")
+        .query(f"{left_on} < {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_equality_datetime(df, right):
-    """Test output for a single condition. "=="."""
+def test_single_condition_less_than_ints(df, right):
+    """Test output for a single condition. "<"."""
     assume(not df.empty)
     assume(not right.empty)
-    # simulate output as it would be in SQL
-    left_on, right_on = ["E", "Dates"]
+    left_on, right_on = ["A", "Integers"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} == {right_on}")
+        .query(f"{left_on} < {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_equality_strings(df, right):
-    """Test output for a single condition. "=="."""
+def test_single_condition_less_than_ints_extension_array(df, right):
+    """Test output for a single condition. "<"."""
     assume(not df.empty)
     assume(not right.empty)
-    # simulate output as it would be in SQL
-    left_on, right_on = ["C", "Strings"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+    left_on, right_on = ["A", "Integers"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} == {right_on}")
+        .query(f"{left_on} < {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_not_equal_numeric(df, right):
-    """Test output for a single condition. "!="."""
+def test_single_condition_less_than_equal(df, right):
+    """Test output for a single condition. "<=". DateTimes"""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["B", "Integers"]
+    left_on, right_on = ["E", "Dates"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} != {right_on}")
+        .query(f"{left_on} <= {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "<="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_not_equal_ints_only(df, right):
-    """Test output for a single condition. "!="."""
+def test_single_condition_less_than_date(df, right):
+    """Test output for a single condition. "<". Dates"""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["A", "Integers"]
+    left_on, right_on = ["E", "Dates"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} != {right_on}")
+        .query(f"{left_on} < {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_not_equal_floats_only(df, right):
-    """Test output for a single condition. "!="."""
+def test_single_condition_greater_than_datetime(df, right):
+    """Test output for a single condition. ">". Datetimes"""
     assume(not df.empty)
     assume(not right.empty)
-    # simulate output as it would be in SQL
-    left_on, right_on = ["B", "Numeric"]
+    left_on, right_on = ["E", "Dates"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} != {right_on}")
+        .query(f"{left_on} > {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_not_equal_datetime(df, right):
-    """Test output for a single condition. "!="."""
+def test_single_condition_greater_than_ints(df, right):
+    """Test output for a single condition. ">="."""
     assume(not df.empty)
     assume(not right.empty)
-    # simulate output as it would be in SQL
-    left_on, right_on = ["E", "Dates"]
+    left_on, right_on = ["A", "Integers"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} != {right_on}")
+        .query(f"{left_on} >= {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, ">="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_how_left(df, right):
-    """Test output when `how==left`. "<="."""
+def test_single_condition_greater_than_floats_floats(df, right):
+    """Test output for a single condition. ">"."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["A", "Integers"]
+    left_on, right_on = ["B", "Numeric"]
     expected = (
-        df.assign(t=1, index=np.arange(len(df)))
+        df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} <= {right_on}")
+        .query(f"{left_on} > {right_on}")
+        .reset_index(drop=True)
     )
-    expected = expected.set_index("index")
-    expected.index.name = None
-    expected = df.join(
-        expected.filter(right.columns), how="left", sort=False
-    ).reset_index(drop=True)
+    expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "<="), how="left", sort_by_appearance=True
+        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_how_right(df, right):
-    """Test output when `how==right`. ">"."""
+def test_single_condition_greater_than_ints_extension_array(df, right):
+    """Test output for a single condition. ">="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["E", "Dates"]
+    left_on, right_on = ["A", "Integers"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
     expected = (
         df.assign(t=1)
-        .merge(right.assign(t=1, index=np.arange(len(right))), on="t")
+        .merge(right.assign(t=1), on="t")
         .query(f"{left_on} > {right_on}")
-    )
-    expected = expected.set_index("index")
-    expected.index.name = None
-    expected = (
-        expected.filter(df.columns)
-        .join(right, how="right", sort=False)
         .reset_index(drop=True)
     )
+    expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, ">"), how="right", sort_by_appearance=True
+        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_less_than_floats(df, right):
-    """Test output for a single condition. "<".Floats vs Floats."""
+def test_single_condition_not_equal_numeric(df, right):
+    """Test output for a single condition. "!="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["B", "Numeric"]
+    left_on, right_on = ["A", "Integers"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} < {right_on}")
+        .dropna(subset=["A", "Integers"])
+        .query(f"{left_on} != {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_less_than_int_float(df, right):
-    """Test output for a single condition. "<".Ints vs Floats."""
+def test_single_condition_not_equal_ints_only(df, right):
+    """Test output for a single condition. "!="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["A", "Numeric"]
+    left_on, right_on = ["A", "Integers"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} < {right_on}")
+        .dropna(subset=["A", "Integers"])
+        .query(f"{left_on} != {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_less_than_equal(df, right):
-    """Test output for a single condition. "<=". DateTimes"""
+def test_single_condition_not_equal_floats_only(df, right):
+    """Test output for a single condition. "!="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["E", "Dates"]
+    left_on, right_on = ["B", "Numeric"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} <= {right_on}")
+        .dropna(subset=["B", "Numeric"])
+        .query(f"{left_on} != {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "<="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_less_than_date(df, right):
-    """Test output for a single condition. "<". Dates"""
+def test_single_condition_not_equal_datetime(df, right):
+    """Test output for a single condition. "!="."""
     assume(not df.empty)
     assume(not right.empty)
     left_on, right_on = ["E", "Dates"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} < {right_on}")
+        .dropna(subset=["E", "Dates"])
+        .query(f"{left_on} != {right_on}")
         .reset_index(drop=True)
     )
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, "<"), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "!="), how="inner", sort_by_appearance=True
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_greater_than_datetime(df, right):
-    """Test output for a single condition. ">". Datetimes"""
+def test_single_condition_equality_string(df, right):
+    """Test output for a single condition. "=="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["E", "Dates"]
-    expected = (
-        df.assign(t=1)
-        .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} > {right_on}")
-        .reset_index(drop=True)
+    left_on, right_on = ["C", "Strings"]
+    expected = df.dropna(subset=[left_on]).merge(
+        right.dropna(subset=[right_on]), left_on=left_on, right_on=right_on
     )
+    expected = expected.reset_index(drop=True)
+
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=False
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_greater_than_ints(df, right):
-    """Test output for a single condition. ">=". Ints vs Ints"""
+def test_single_condition_equality_category(df, right):
+    """Test output for a single condition. "=="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["A", "Integers"]
-    expected = (
-        df.assign(t=1)
-        .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} >= {right_on}")
-        .reset_index(drop=True)
+    left_on, right_on = ["C", "Strings"]
+    df = df.assign(C=df["C"].astype("string"))
+    right = right.assign(Strings=right["Strings"].astype("string"))
+    expected = df.dropna(subset=[left_on]).merge(
+        right.dropna(subset=[right_on]), left_on=left_on, right_on=right_on
     )
+    expected = expected.reset_index(drop=True)
+
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, ">="), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=False
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_single_condition_greater_than_ints_floats(df, right):
-    """Test output for a single condition. ">". Ints vs Floats"""
+def test_single_condition_equality_numeric(df, right):
+    """Test output for a single condition. "=="."""
     assume(not df.empty)
     assume(not right.empty)
-    left_on, right_on = ["A", "Numeric"]
-    expected = (
-        df.assign(t=1)
-        .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} > {right_on}")
-        .reset_index(drop=True)
+    left_on, right_on = ["A", "Integers"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+    df.loc[0, "A"] = pd.NA
+    right.loc[0, "Integers"] = pd.NA
+    expected = df.dropna(subset=[left_on]).merge(
+        right.dropna(subset=[right_on]), left_on=left_on, right_on=right_on
     )
+    expected = expected.reset_index(drop=True)
+
     expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right, (left_on, right_on, ">"), how="inner", sort_by_appearance=True
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=False
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, right_on])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_dual_conditions_gt_and_lt_dates(df, right):
-    """Test output for interval conditions."""
+def test_single_condition_equality_datetime(df, right):
+    """Test output for a single condition. "=="."""
     assume(not df.empty)
     assume(not right.empty)
-    middle, left_on, right_on = ("E", "Dates", "Dates_Right")
-    expected = (
-        df.assign(t=1)
-        .merge(right.assign(t=1), on="t")
-        .query(f"{left_on} < {middle} < {right_on}")
-        .reset_index(drop=True)
+    left_on, right_on = ["E", "Dates"]
+    expected = df.dropna(subset=[left_on]).merge(
+        right.dropna(subset=[right_on]), left_on=left_on, right_on=right_on
     )
-    expected = expected.filter([left_on, middle, right_on])
+    expected = expected.reset_index(drop=True)
+    expected = expected.filter([left_on, right_on])
     actual = df.conditional_join(
-        right,
-        (middle, left_on, ">"),
+        right, (left_on, right_on, "=="), how="inner", sort_by_appearance=False
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter([left_on, right_on])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_how_left(df, right):
+    """Test output when `how==left`. "<="."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["A", "Integers"]
+    expected = (
+        df.assign(t=1, index=np.arange(len(df)))
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} <= {right_on}")
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = df.join(
+        expected.filter(right.columns), how="left", sort=False
+    ).reset_index(drop=True)
+    actual = df.conditional_join(
+        right, (left_on, right_on, "<="), how="left", sort_by_appearance=True
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_how_right(df, right):
+    """Test output when `how==right`. ">"."""
+    assume(not df.empty)
+    assume(not right.empty)
+    left_on, right_on = ["E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1, index=np.arange(len(right))), on="t")
+        .query(f"{left_on} > {right_on}")
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = (
+        expected.filter(df.columns)
+        .join(right, how="right", sort=False)
+        .reset_index(drop=True)
+    )
+    actual = df.conditional_join(
+        right, (left_on, right_on, ">"), how="right", sort_by_appearance=True
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_gt_and_lt_dates(df, right):
+    """Test output for interval conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    middle, left_on, right_on = ("E", "Dates", "Dates_Right")
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(f"{left_on} < {middle} < {right_on}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([left_on, middle, right_on])
+    actual = df.conditional_join(
+        right,
+        (middle, left_on, ">"),
         (middle, right_on, "<"),
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -694,6 +707,7 @@ def test_dual_conditions_ge_and_le_dates(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -718,6 +732,7 @@ def test_dual_conditions_le_and_ge_dates(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -742,6 +757,7 @@ def test_dual_conditions_ge_and_le_numbers(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -766,6 +782,7 @@ def test_dual_conditions_le_and_ge_numbers(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -790,6 +807,7 @@ def test_dual_conditions_gt_and_lt_numbers(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([left_on, middle, right_on])
     assert_frame_equal(expected, actual)
 
@@ -797,8 +815,7 @@ def test_dual_conditions_gt_and_lt_numbers(df, right):
 @given(df=conditional_df(), right=conditional_right())
 def test_dual_conditions_gt_and_lt_numbers_(df, right):
     """
-    Test output for multiple columns from the left
-    and single column from right.
+    Test output for multiple conditions.
     """
     assume(not df.empty)
     assume(not right.empty)
@@ -817,35 +834,234 @@ def test_dual_conditions_gt_and_lt_numbers_(df, right):
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
     actual = actual.filter([first, second, third])
     assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
-def test_dual_conditions_eq_and_ne(df, right):
-    """Test output for equal and not equal conditions."""
+def test_dual_conditions_gt_and_lt_numbers_left_join(df, right):
+    """
+    Test output for multiple conditions, and how is `left`.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    first, second, third = ("Numeric", "Floats", "B")
+    right = right.assign(t=1, check=range(len(right)))
+    df = df.assign(t=1)
+    expected = right.merge(df, on="t").query(
+        f"{first} > {third} and {second} < {third}"
+    )
+    drop = right.columns.difference(["check"])
+    expected = right.merge(
+        expected.drop(columns=[*drop]), on="check", how="left", sort=False
+    )
+    expected = expected.filter([first, second, third])
+    actual = right.conditional_join(
+        df,
+        (first, third, ">"),
+        (second, third, "<"),
+        how="left",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.loc[:, [first, second, third]]
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_gt_and_lt_numbers_right_join(df, right):
+    """
+    Test output for multiple conditions, and how is `right`.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    first, second, third = ("Numeric", "Floats", "B")
+    df = df.assign(t=1, check=range(len(df)))
+    right = right.assign(t=1)
+    expected = right.merge(df, on="t").query(
+        f"{first} > {third} and {second} < {third}"
+    )
+    drop = df.columns.difference(["check"])
+    expected = expected.drop(columns=[*drop]).merge(
+        df, on="check", how="right", sort=False
+    )
+    expected = expected.filter([first, second, third])
+    actual = right.conditional_join(
+        df,
+        (first, third, ">"),
+        (second, third, "<"),
+        how="right",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.loc[:, [first, second, third]]
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ne(df, right):
+    """
+    Test output for multiple conditions. `!=`
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A != Integers and B != Numeric")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("A", "Integers", "!="),
+        ("B", "Numeric", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ne_extension(df, right):
+    """
+    Test output for multiple conditions. `!=`
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A != Integers and B != Numeric")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("A", "Integers", "!="),
+        ("B", "Numeric", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ne_extension_right(df, right):
+    """
+    Test output for multiple conditions. `!=`
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric"]
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A != Integers and B != Numeric")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("A", "Integers", "!="),
+        ("B", "Numeric", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ne_dates(df, right):
+    """
+    Test output for multiple conditions. `!=`
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A != Integers and E != Dates")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("A", "Integers", "!="),
+        ("E", "Dates", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_multiple_ne_dates(df, right):
+    """
+    Test output for multiple conditions. `!=`
+    """
     assume(not df.empty)
     assume(not right.empty)
-    eq_A, eq_B, ne_A, ne_B = ("B", "Numeric", "E", "Dates")
+    filters = ["A", "Integers", "E", "Dates", "B", "Numeric"]
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{eq_A} == {eq_B} and {ne_A} != {ne_B}")
+        .query("A != Integers and E != Dates and B != Numeric")
         .reset_index(drop=True)
     )
-    # nulls are not preserved for multiple conditions
-    # that involve `!=`
-    expected = expected.filter([eq_A, eq_B, ne_A, ne_B]).dropna()
+    expected = expected.filter(filters)
     actual = df.conditional_join(
         right,
-        (eq_A, eq_B, "=="),
-        (ne_A, ne_B, "!="),
+        ("A", "Integers", "!="),
+        ("E", "Dates", "!="),
+        ("B", "Numeric", "!="),
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
 
-    actual = actual.filter([eq_A, eq_B, ne_A, ne_B])
-    assert_frame_equal(actual, actual)
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_conditions_eq_and_ne(df, right):
+    """Test output for equal and not equal conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    A, B, C, D = ("B", "Numeric", "E", "Dates")
+    expected = (
+        df.merge(right, left_on=A, right_on=B)
+        .dropna(subset=[A, B])
+        .query(f"{C} != {D}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([A, B, C, D])
+    actual = df.conditional_join(
+        right,
+        (A, B, "=="),
+        (C, D, "!="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter([A, B, C, D])
+    assert_frame_equal(expected, actual)
 
 
 @given(df=conditional_df(), right=conditional_right())
@@ -855,23 +1071,302 @@ def test_dual_conditions_ne_and_eq(df, right):
     assume(not df.empty)
     assume(not right.empty)
 
-    eq_A, eq_B, ne_A, ne_B = ("B", "Numeric", "E", "Dates")
+    A, B, C, D = ("A", "Integers", "E", "Dates")
+    expected = (
+        df.merge(right, left_on=C, right_on=D)
+        .dropna(subset=[C, D])
+        .query(f"{A} != {B}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter([A, B, C, D])
+    actual = df.conditional_join(
+        right,
+        (A, B, "!="),
+        (C, D, "=="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter([A, B, C, D])
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_gt_lt_ne_conditions(df, right):
+    """
+    Test output for multiple conditions.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric", "E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A > Integers and B < Numeric and E != Dates")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("A", "Integers", ">"),
+        ("B", "Numeric", "<"),
+        ("E", "Dates", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_gt_lt_ne_start(df, right):
+    """
+    Test output for multiple conditions.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric", "E", "Dates"]
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query("A > Integers and B < Numeric and E != Dates")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("E", "Dates", "!="),
+        ("A", "Integers", ">"),
+        ("B", "Numeric", "<"),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_ge_le_ne_extension_array(df, right):
+    """
+    Test output for multiple conditions.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric", "E", "Dates"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+
     expected = (
         df.assign(t=1)
         .merge(right.assign(t=1), on="t")
-        .query(f"{eq_A} != {eq_B} and {ne_A} == {ne_B}")
+        .query("A != Integers and B < Numeric and E >= Dates")
         .reset_index(drop=True)
     )
-    # nulls are not preserved for multiple conditions
-    # that involve `!=`
-    expected = expected.filter([eq_A, eq_B, ne_A, ne_B]).dropna()
+    expected = expected.filter(filters)
     actual = df.conditional_join(
         right,
-        (eq_A, eq_B, "!="),
-        (ne_A, ne_B, "=="),
+        ("E", "Dates", ">="),
+        ("A", "Integers", "!="),
+        ("B", "Numeric", "<"),
         how="inner",
         sort_by_appearance=True,
     )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
 
-    actual = actual.filter([eq_A, eq_B, ne_A, ne_B])
+@given(df=conditional_df(), right=conditional_right())
+def test_ge_lt_ne_extension(df, right):
+    """
+    Test output for multiple conditions.
+    """
+    assume(not df.empty)
+    assume(not right.empty)
+    filters = ["A", "Integers", "B", "Numeric", "E", "Dates"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+
+    expected = (
+        df.assign(t=1)
+        .merge(right.assign(t=1), on="t")
+        .query(
+            "A != Integers and B < Numeric and E >= Dates and E != Dates_Right"
+        )
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(filters)
+    actual = df.conditional_join(
+        right,
+        ("E", "Dates", ">="),
+        ("B", "Numeric", "<"),
+        ("A", "Integers", "!="),
+        ("E", "Dates_Right", "!="),
+        how="inner",
+        sort_by_appearance=True,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(filters)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_eq_ge_and_le_numbers(df, right):
+    """Test output for multiple conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    l_eq, l_ge, l_le = ["B", "A", "E"]
+    r_eq, r_ge, r_le = ["Floats", "Integers", "Dates"]
+    columns = ["B", "A", "E", "Floats", "Integers", "Dates"]
+    expected = (
+        df.merge(right, left_on=l_eq, right_on=r_eq, how="inner", sort=False)
+        .dropna(subset=[l_eq, r_eq])
+        .query(f"{l_ge} >= {r_ge} and {l_le} <= {r_le}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(columns)
+    actual = df.conditional_join(
+        right,
+        (l_eq, r_eq, "=="),
+        (l_ge, r_ge, ">="),
+        (l_le, r_le, "<="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(columns)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_ge_eq_and_le_numbers(df, right):
+    """Test output for multiple conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    l_eq, l_ge, l_le = ["B", "A", "E"]
+    r_eq, r_ge, r_le = ["Floats", "Integers", "Dates"]
+    columns = ["B", "A", "E", "Floats", "Integers", "Dates"]
+    expected = (
+        df.merge(right, left_on=l_eq, right_on=r_eq, how="inner", sort=False)
+        .dropna(subset=[l_eq, r_eq])
+        .query(f"{l_ge} >= {r_ge} and {l_le} <= {r_le}")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(columns)
+    actual = df.conditional_join(
+        right,
+        (l_ge, r_ge, ">="),
+        (l_le, r_le, "<="),
+        (l_eq, r_eq, "=="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(columns)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_multiple_eqs(df, right):
+    """Test output for multiple conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    columns = ["B", "A", "E", "Floats", "Integers", "Dates"]
+    expected = (
+        df.merge(
+            right,
+            left_on=["B", "A"],
+            right_on=["Floats", "Integers"],
+            how="inner",
+            sort=False,
+        )
+        .dropna(subset=["B", "A", "Floats", "Integers"])
+        .query("E != Dates")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(columns)
+    actual = df.conditional_join(
+        right,
+        ("E", "Dates", "!="),
+        ("B", "Floats", "=="),
+        ("A", "Integers", "=="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(columns)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_multiple_eqs_extension_array(df, right):
+    """Test output for multiple conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    columns = ["B", "A", "E", "Floats", "Integers", "Dates"]
+    df = df.assign(A=df["A"].astype("Int64"))
+    right = right.assign(Integers=right["Integers"].astype(pd.Int64Dtype()))
+    expected = (
+        df.merge(
+            right,
+            left_on=["B", "A"],
+            right_on=["Floats", "Integers"],
+            how="inner",
+            sort=False,
+        )
+        .dropna(subset=["B", "A", "Floats", "Integers"])
+        .query("E != Dates")
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(columns)
+    actual = df.conditional_join(
+        right,
+        ("E", "Dates", "!="),
+        ("B", "Floats", "=="),
+        ("A", "Integers", "=="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(columns)
+    assert_frame_equal(expected, actual)
+
+
+@given(df=conditional_df(), right=conditional_right())
+def test_multiple_eqs_only(df, right):
+    """Test output for multiple conditions."""
+    assume(not df.empty)
+    assume(not right.empty)
+    columns = ["B", "A", "E", "Floats", "Integers", "Dates"]
+    df = df.assign(A=df["A"].astype("Int64"), C=df["C"].astype("string"))
+    right = right.assign(
+        Integers=right["Integers"].astype(pd.Int64Dtype()),
+        Strings=right["Strings"].astype("string"),
+    )
+    df.loc[0, "A"] = pd.NA
+    right.loc[0, "Integers"] = pd.NA
+    expected = (
+        df.merge(
+            right,
+            left_on=["B", "A", "E"],
+            right_on=["Floats", "Integers", "Dates"],
+            how="inner",
+            sort=False,
+        )
+        .dropna(subset=columns)
+        .reset_index(drop=True)
+    )
+    expected = expected.filter(columns)
+    actual = df.conditional_join(
+        right,
+        ("E", "Dates", "=="),
+        ("B", "Floats", "=="),
+        ("A", "Integers", "=="),
+        how="inner",
+        sort_by_appearance=False,
+    )
+    actual = actual.droplevel(level=0, axis=1)
+    actual = actual.filter(columns)
     assert_frame_equal(expected, actual)