Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Improve selection/perf in `conditional_join`
- Improve selection in `conditional_join`, such that only a single dataframe (left/right) is returned
- simplify the numba implementation
- add `indicator` parameter , similar to the `indicator` parameter in `pd.merge`



Discussion:

Resolving patch:
diff --git a/janitor/functions/_numba.py b/janitor/functions/_numba.py
--- a/janitor/functions/_numba.py
+++ b/janitor/functions/_numba.py
@@ -2,39 +2,19 @@
 
 import numpy as np
 import pandas as pd
-from janitor.functions.utils import _convert_to_numpy_array
+from janitor.functions.utils import (
+    _generic_func_cond_join,
+    _JoinOperator,
+    less_than_join_types,
+    greater_than_join_types,
+)
 from numba import njit, prange
-from enum import Enum
 
 
-class _JoinOperator(Enum):
+def _numba_dual_join(df: pd.DataFrame, right: pd.DataFrame, pair: list):
     """
-    List of operators used in conditional_join.
-    """
-
-    GREATER_THAN = ">"
-    LESS_THAN = "<"
-    GREATER_THAN_OR_EQUAL = ">="
-    LESS_THAN_OR_EQUAL = "<="
-    STRICTLY_EQUAL = "=="
-    NOT_EQUAL = "!="
-
-
-less_than_join_types = {
-    _JoinOperator.LESS_THAN.value,
-    _JoinOperator.LESS_THAN_OR_EQUAL.value,
-}
-greater_than_join_types = {
-    _JoinOperator.GREATER_THAN.value,
-    _JoinOperator.GREATER_THAN_OR_EQUAL.value,
-}
-
-
-def _numba_pair_le_lt(df: pd.DataFrame, right: pd.DataFrame, pair: list):
-    """
-    Numba implementation of algorithm in this paper:
     # https://www.scitepress.org/papers/2018/68268/68268.pdf
-    Generally faster than the _range_indices algorithm
+    An alternative to the _range_indices algorithm
     and more generalised - it covers any pair of non equi joins
     in >, >=, <, <=.
     Returns a tuple of left and right indices.
@@ -179,72 +159,75 @@ def _numba_pair_le_lt(df: pd.DataFrame, right: pd.DataFrame, pair: list):
     # 3        4         3         5
     # 4        4         3         6
     ################################
-
+    mapping = {">=": "<=", ">": "<", "<=": ">=", "<": ">"}
     left_indices = []
     right_indices = []
     left_regions = []
     right_regions = []
     left_index = df.index
     right_index = right.index
-    for num, (left_on, right_on, op) in enumerate(pair):
-        # indexing is expensive; avoid it if possible
+    for num, pairing in enumerate(pair):
+        left_on, right_on, op = pairing
         if num:
             left_c = df.loc[left_index, left_on]
             right_c = right.loc[right_index, right_on]
         else:
             left_c = df[left_on]
             right_c = right[right_on]
-
-        any_nulls = pd.isna(right_c)
-        if any_nulls.all():
-            return None
-        if any_nulls.any():
-            right_c = right_c[~any_nulls]
-        any_nulls = pd.isna(left_c)
-        if any_nulls.all():
-            return None
-        if any_nulls.any():
-            left_c = left_c[~any_nulls]
-
-        if op in less_than_join_types:
-            left_is_sorted = pd.Series(left_c).is_monotonic_increasing
-            if not left_is_sorted:
-                left_c = left_c.sort_values(kind="stable", ascending=True)
-        else:
-            left_is_sorted = pd.Series(left_c).is_monotonic_decreasing
-            if not left_is_sorted:
-                left_c = left_c.sort_values(kind="stable", ascending=False)
-
-        left_index = left_c.index._values
-        right_index = right_c.index._values
-        left_c, right_c = _convert_to_numpy_array(
-            left_c._values, right_c._values
+        left_on, right_on, op = pairing
+        # the flip is because the left Series
+        # is the control point
+        # and we can easily get the regions back
+        # by keeping track and using the logic
+        # explained below
+        outcome = _generic_func_cond_join(
+            left=right_c,
+            right=left_c,
+            op=mapping[op],
+            multiple_conditions=True,
+            keep="all",
         )
-
-        if op in {
-            _JoinOperator.LESS_THAN.value,
-            _JoinOperator.GREATER_THAN.value,
-        }:
-            strict = 1
-        else:
-            strict = 0
-        if op in less_than_join_types:
-            op_code = 1
-        else:
-            op_code = 0
-
-        result = _get_regions(
-            left_c, left_index, right_c, right_index, strict, op_code
-        )
-        if result is None:
+        if not outcome:
             return None
-        (
-            left_index,
-            right_index,
-            left_region,
-            right_region,
-        ) = result
-
+        right_index, left_index, search_indices = outcome
+
+        if op in greater_than_join_types:
+            left_index = left_index[::-1]
+            search_indices = left_index.size - search_indices
+        # logic for computing regions
+        # relies on binary search
+        # subtract 1 from search indices
+        # to align it with the lowest value for left region
+        # say we had 2, 3, 5, 8, for the left region
+        # and 6 for the right region
+        # and is a < operation
+        # a binary search returns 3
+        # subtracting 1, yields 2,
+        # which pairs it correctly with 5
+        # since 2, 3, 5 are less than 6
+        # if it was a > operation
+        # first a subtraction from len(left) -> 4 - 3
+        # which yields 1
+        # flipping the left region in descending order
+        # -> 8, 3, 5 ,2
+        # subtract 1 from the search index yields 0
+        # which correctly pairs with 8,
+        # since 8 is the first closest number greater than 6
+        # from here on we can compute the regions
+        right_region, indexer = pd.factorize(search_indices - 1, sort=True)
+        length = indexer[-1] + 1
+        if length < left_index.size:
+            left_index = left_index[:length]
+        left_region = np.full(shape=length, dtype=np.intp, fill_value=-1)
+        indexer_pos = np.arange(indexer.size)
+        left_region[indexer] = indexer_pos
+        bools = left_region == -1
+        # spool through to fill up empty points
+        # with the immediate next region number
+        if bools.any():
+            fill_pos = bools.nonzero()[0]
+            fill_pos = indexer.searchsorted(fill_pos, side="left")
+            left_region[bools] = indexer_pos[fill_pos]
         left_indices.append(left_index)
         right_indices.append(right_index)
         left_regions.append(left_region)
@@ -273,8 +256,8 @@ def _realign(indices, regions):
         region2 = region2[indexer]
         return arr1, region1, region2
 
-    l_index, l_table1, l_table2 = _realign(left_indices, left_regions)
-    r_index, r_table1, r_table2 = _realign(right_indices, right_regions)
+    left_index, l_table1, l_table2 = _realign(left_indices, left_regions)
+    right_index, r_table1, r_table2 = _realign(right_indices, right_regions)
 
     del (
         left_indices,
@@ -285,14 +268,15 @@ def _realign(indices, regions):
         right_region,
     )
 
-    # we'll be running a for loop to check sub arrays
-    # to see if the region from the left is less than
-    # the region on the right
-    # sorting here allows us to search each first level
-    # array more efficiently with a binary search
+    # we'll be running a for-loop/binary search
+    # in r_table2 to get positions
+    # where l_table2 is less than or equal to r_table2
+    # sorting here allows us to search in the first level
+    # for positions where l_table1 <= r_table1
+    # efficiently with a binary search
     if not pd.Series(r_table1).is_monotonic_increasing:
         indexer = np.lexsort((r_table2, r_table1))
-        r_index, r_table1, r_table2 = (
+        right_index, r_table1, r_table2 = (
             right_index[indexer],
             r_table1[indexer],
             r_table2[indexer],
@@ -308,9 +292,32 @@ def _realign(indices, regions):
         return None
     if not bools.all():
         positions = positions[bools]
-        l_index = l_index[bools]
+        left_index = left_index[bools]
         l_table2 = l_table2[bools]
 
+    if pd.Series(r_table2).is_monotonic_decreasing:
+        # our work here is significantly easier
+        # as we can run a binary search to find exact positions
+        # which is better than running a linear search on every entry
+        # to check where l_table2 <= r_table2
+        ends = r_table2.size - r_table2[::-1].searchsorted(
+            l_table2, side="left"
+        )
+        keep_rows = positions < ends
+        if not keep_rows.all():
+            positions = positions[keep_rows]
+            ends = ends[keep_rows]
+            left_index = left_index[keep_rows]
+        if ((ends - positions) == 1).all():
+            return left_index, right_index[positions]
+        return _get_indices_single(
+            l_index=left_index,
+            r_index=right_index,
+            counts=ends - positions,
+            starts=positions,
+            ends=ends,
+        )
+
     # find the maximum from the bottom upwards
     # if value from l_table2 is greater than the maximum
     # there is no point searching within that space
@@ -321,302 +328,132 @@ def _realign(indices, regions):
         return None
     if bools.any():
         positions = positions[~bools]
-        l_index = l_index[~bools]
+        left_index = left_index[~bools]
         l_table2 = l_table2[~bools]
-
-    return _get_matching_indices(
-        l_index, l_table2, r_index, r_table2, positions, max_arr
+    # this is where numba comes in
+    # to improve performance for the for-loop
+    return _get_indices_dual(
+        left_index, l_table2, right_index, r_table2, positions, max_arr
     )
 
 
 def _numba_single_join(
     left: pd.Series,
     right: pd.Series,
-    strict: bool,
+    op: str,
     keep: str,
-    op_code: int,
 ) -> tuple:
     """Return matching indices for single non-equi join."""
-    if op_code == -1:
-        # for the not equal join, we combine indices
-        # from strictly less than and strictly greater than indices
-        # as well as indices for nulls, if any
-        left_nulls, right_nulls = _numba_not_equal_indices(left, right)
-        dummy = np.array([], dtype=int)
-        result = _numba_less_than_indices(left, right)
-        if result is None:
-            lt_left = dummy
-            lt_right = dummy
-        else:
-            lt_left, lt_right = _numba_generate_indices_ne(
-                *result, strict, keep, op_code=1
-            )
-        result = _numba_greater_than_indices(left, right)
-        if result is None:
-            gt_left = dummy
-            gt_right = dummy
-        else:
-            gt_left, gt_right = _numba_generate_indices_ne(
-                *result, strict, keep, op_code=0
-            )
-        left = np.concatenate([lt_left, gt_left, left_nulls])
-        right = np.concatenate([lt_right, gt_right, right_nulls])
-        if (not left.size) & (not right.size):
-            return None
-        if keep == "all":
-            return left, right
-        indexer = np.argsort(left)
-        left, pos = np.unique(left[indexer], return_index=True)
-        if keep == "first":
-            right = np.minimum.reduceat(right[indexer], pos)
-        else:
-            right = np.maximum.reduceat(right[indexer], pos)
-        return left, right
-
-    # convert Series to numpy arrays
-    # get the regions for left and right
-    # get the total count of indices
-    # build the final left and right indices
-    if op_code == 1:
-        result = _numba_less_than_indices(left, right)
-    else:
-        result = _numba_greater_than_indices(left, right)
-    if result is None:
-        return None
-    result = _get_regions(*result, strict, op_code)
-    if result is None:
-        return None
-    left_index, right_index, left_region, right_region = result
-    # numpy version of pandas monotonic increasing
-    bools = np.all(right_region[1:] >= right_region[:-1])
-    if not bools:
-        indexer = np.lexsort((right_index, right_region))
-        right_region = right_region[indexer]
-        right_index = right_index[indexer]
-    positions = right_region.searchsorted(left_region, side="left")
-    if keep == "all":
-        # get actual length of left and right indices
-        counts = right_region.size - positions
-        counts = counts.cumsum()
-        return _numba_single_non_equi(
-            left_index, right_index, counts, positions
-        )
-    return _numba_single_non_equi_keep_first_last(
-        left_index, right_index, positions, keep
-    )
-
 
-def _numba_generate_indices_ne(
-    left: np.ndarray,
-    left_index: np.ndarray,
-    right: np.ndarray,
-    right_index: np.ndarray,
-    strict: bool,
-    keep: str,
-    op_code: int,
-) -> tuple:
-    """
-    Generate indices within a not equal join,
-    for either greater or less than.
-    if op_code is 1, that is a less than operation,
-    if op_code is 0, that is a greater than operation.
-    """
-    dummy = np.array([], dtype=int)
-    result = _get_regions(
-        left, left_index, right, right_index, strict, op_code
-    )
-    if result is None:
-        return dummy, dummy
-    left_index, right_index, left_region, right_region = result
-    # numpy version of pandas monotonic increasing
-    bools = np.all(right_region[1:] >= right_region[:-1])
-    if not bools:
-        indexer = np.lexsort((right_index, right_region))
-        right_region = right_region[indexer]
-        right_index = right_index[indexer]
-    positions = right_region.searchsorted(left_region, side="left")
-    if keep == "all":
-        # get actual length of left and right indices
-        counts = right_region.size - positions
-        counts = counts.cumsum()
-        return _numba_single_non_equi(
-            left_index, right_index, counts, positions
-        )
-    return _numba_single_non_equi_keep_first_last(
-        left_index, right_index, positions, keep
+    outcome = _generic_func_cond_join(
+        left=left,
+        right=right,
+        op=op,
+        multiple_conditions=True,
+        keep=keep,
     )
 
+    if (outcome is None) or (op == _JoinOperator.NOT_EQUAL.value):
+        return outcome
 
-def _numba_not_equal_indices(left_c: pd.Series, right_c: pd.Series) -> tuple:
-    """
-    Preparatory function for _numba_single_join
-    This retrieves the indices for nulls, if any.
-    """
-
-    dummy = np.array([], dtype=int)
-
-    # deal with nulls
-    l1_nulls = dummy
-    r1_nulls = dummy
-    l2_nulls = dummy
-    r2_nulls = dummy
-    any_left_nulls = left_c.isna()
-    any_right_nulls = right_c.isna()
-    if any_left_nulls.any():
-        l1_nulls = left_c.index[any_left_nulls]
-        l1_nulls = l1_nulls._values
-        r1_nulls = right_c.index
-        # avoid NAN duplicates
-        if any_right_nulls.any():
-            r1_nulls = r1_nulls[~any_right_nulls]
-        r1_nulls = r1_nulls._values
-        nulls_count = l1_nulls.size
-        # blow up nulls to match length of right
-        l1_nulls = np.tile(l1_nulls, r1_nulls.size)
-        # ensure length of right matches left
-        if nulls_count > 1:
-            r1_nulls = np.repeat(r1_nulls, nulls_count)
-    if any_right_nulls.any():
-        r2_nulls = right_c.index[any_right_nulls]
-        r2_nulls = r2_nulls._values
-        l2_nulls = left_c.index
-        nulls_count = r2_nulls.size
-        # blow up nulls to match length of left
-        r2_nulls = np.tile(r2_nulls, l2_nulls.size)
-        # ensure length of left matches right
-        if nulls_count > 1:
-            l2_nulls = np.repeat(l2_nulls, nulls_count)
-    l1_nulls = np.concatenate([l1_nulls, l2_nulls])
-    r1_nulls = np.concatenate([r1_nulls, r2_nulls])
-
-    return l1_nulls, r1_nulls
-
-
-def _numba_less_than_indices(
-    left: pd.Series,
-    right: pd.Series,
-) -> tuple:
-    """
-    Preparatory function for _numba_single_join
-    """
-
-    if left.min() > right.max():
-        return None
-    any_nulls = pd.isna(left)
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        left = left[~any_nulls]
-    if not left.is_monotonic_increasing:
-        left = left.sort_values(kind="stable", ascending=True)
-    any_nulls = pd.isna(right)
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        right = right[~any_nulls]
-    any_nulls = None
-    left_index = left.index._values
-    right_index = right.index._values
-    left, right = _convert_to_numpy_array(left._values, right._values)
-    return left, left_index, right, right_index
-
+    left_index, right_index, search_indices = outcome
 
-def _numba_greater_than_indices(
-    left: pd.Series,
-    right: pd.Series,
-) -> tuple:
-    """
-    Preparatory function for _numba_single_join
-    """
-    if left.max() < right.min():
-        return None
-
-    any_nulls = pd.isna(left)
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        left = left[~any_nulls]
-    if not left.is_monotonic_decreasing:
-        left = left.sort_values(kind="stable", ascending=False)
-    any_nulls = pd.isna(right)
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        right = right[~any_nulls]
+    if op in greater_than_join_types:
+        starts = np.zeros(shape=search_indices.size, dtype=np.int8)
+        ends = search_indices
+        counts = search_indices
+    else:
+        ends = np.full(
+            shape=search_indices.size,
+            dtype=np.intp,
+            fill_value=right_index.size,
+        )
+        starts = search_indices
+        counts = ends - starts
+    if keep == "all":
+        return _get_indices_single(
+            l_index=left_index,
+            r_index=right_index,
+            counts=counts,
+            starts=starts,
+            ends=ends,
+        )
 
-    any_nulls = None
-    left_index = left.index._values
-    right_index = right.index._values
-    left, right = _convert_to_numpy_array(left._values, right._values)
-    return left, left_index, right, right_index
+    if (
+        (keep == "first")
+        and (op in less_than_join_types)
+        and pd.Series(right_index).is_monotonic_increasing
+    ):
+        return left_index, right_index[search_indices]
+    if (
+        (keep == "last")
+        and (op in greater_than_join_types)
+        and pd.Series(right_index).is_monotonic_increasing
+    ):
+        return left_index, right_index[search_indices - 1]
+    if keep == "first":
+        right_index = _numba_single_non_equi_keep_first(
+            right_index, starts, ends
+        )
+    else:
+        right_index = _numba_single_non_equi_keep_last(
+            right_index, starts, ends
+        )
+    return left_index, right_index
 
 
 @njit(parallel=True)
-def _numba_single_non_equi(
-    left_index: np.ndarray,
-    right_index: np.ndarray,
-    counts: np.ndarray,
-    positions: np.ndarray,
-) -> tuple:
+def _numba_single_non_equi_keep_first(
+    right_index: np.ndarray, starts, ends
+) -> np.ndarray:
     """
-    Generate all indices when keep = `all`.
+    Generate all indices when keep = `first`
     Applies only to >, >= , <, <= operators.
     """
-    length = left_index.size
-    len_right = right_index.size
-    l_index = np.empty(counts[-1], np.intp)
-    r_index = np.empty(counts[-1], np.intp)
-    starts = np.empty(length, np.intp)
-    # capture the starts and ends for each sub range
-    starts[0] = 0
-    starts[1:] = counts[:-1]
-    # build the actual indices
-    for num in prange(length):
-        val = left_index[num]
-        pos = positions[num]
-        posn = starts[num]
-        for ind in range(pos, len_right):
-            r_index[posn] = right_index[ind]
-            l_index[posn] = val
-            posn += 1
-    return l_index, r_index
+    r_index = np.empty(starts.size, np.intp)
+    for num in prange(starts.size):
+        indexer = slice(starts[num], ends[num])
+        r_index[num] = right_index[indexer].min()
+    return r_index
 
 
 @njit(parallel=True)
-def _numba_single_non_equi_keep_first_last(
-    left_index: np.ndarray,
-    right_index: np.ndarray,
-    positions: np.ndarray,
-    keep: str,
-) -> tuple:
+def _numba_single_non_equi_keep_last(
+    right_index: np.ndarray, starts, ends
+) -> np.ndarray:
     """
-    Generate all indices when keep = `first` or `last`
+    Generate all indices when keep = `last`
     Applies only to >, >= , <, <= operators.
     """
-    length = left_index.size
-    l_index = np.empty(length, np.intp)
-    r_index = np.empty(length, np.intp)
+    r_index = np.empty(starts.size, np.intp)
+    for num in prange(starts.size):
+        indexer = slice(starts[num], ends[num])
+        r_index[num] = right_index[indexer].max()
+    return r_index
 
-    len_right = right_index.size
-    for num in prange(length):
-        val = left_index[num]
-        pos = positions[num]
-        base_val = right_index[pos]
-        for ind in range(pos + 1, len_right):
-            value = right_index[ind]
-            if keep == "first":
-                bool_scalar = value < base_val
-            else:
-                bool_scalar = value > base_val
-            if bool_scalar:
-                base_val = value
-        l_index[num] = val
-        r_index[num] = base_val
-    return l_index, r_index
+
+@njit(cache=True, parallel=True)
+def _get_indices_single(l_index, r_index, counts, starts, ends):
+    """ "Compute indices when starts and ends are already known"""
+    lengths = np.cumsum(counts)
+    left_index = np.empty(lengths[-1], np.intp)
+    right_index = np.empty(lengths[-1], np.intp)
+    start_indices = np.empty(lengths.size, np.intp)
+    start_indices[0] = 0
+    start_indices[1:] = lengths[:-1]
+    for num in prange(lengths.size):
+        start = start_indices[num]
+        width = counts[num]
+        l_indexer = slice(start, start + width)
+        left_index[l_indexer] = l_index[num]
+        r_indexer = slice(starts[num], ends[num])
+        right_index[l_indexer] = r_index[r_indexer]
+
+    return left_index, right_index
 
 
 @njit(cache=True, parallel=True)
-def _get_matching_indices(
+def _get_indices_dual(
     l_index, l_table2, r_index, r_table2, positions, max_arr
 ):
     """
@@ -635,7 +472,7 @@ def _get_matching_indices(
         end = 0
         pos_end = length
         # get the first point where l2
-        # is less than the cumulative max
+        # is greater than the cumulative max
         # that will serve as the range
         # (pos, pos_end)
         # within which to search for actual matches
@@ -681,150 +518,3 @@ def _get_matching_indices(
             counter -= 1
 
     return left_index, right_index
-
-
-@njit()
-def _get_regions(
-    left_c: np.ndarray,
-    left_index: np.ndarray,
-    right_c: np.ndarray,
-    right_index: np.ndarray,
-    strict: int,
-    op_code: int,
-) -> tuple:
-    """
-    Get the regions where left_c and right_c converge.
-    Strictly for non-equi joins,
-    specifically  -->  >, >= , <, <= operators.
-    """
-    # The idea is to group values within regions.
-    # An example:
-    # left_array: [2, 5, 7]
-    # right_array: [0, 3, 7]
-    # if the join is left_array <= right_array
-    # we should have pairs (0), (2,3),(5,7),(5,7)
-    # since no value is less than 0, we can discard that
-    # our final regions should be
-    #  (2,3) --->  0
-    #  (5,7) --->  1
-    #  (7,7) --->  1
-    #  based on the regions, we can see that any value in
-    #  region 0 will be less than 1 ---> 2 <= 3, 7
-    #  region 1 values are the end ---> 5 <= 7 & 7 <= 7
-    #  if the join is left_array >= right_array
-    #  then the left_array is sorted in descending order
-    #  and the final pairs should be :
-    #  (7, 7), (5, 3), (2, 0)
-    # our final regions should be
-    #  (7,7) --->  0
-    #  (5,3) --->  1
-    #  (2,0) --->  2
-    #  based on the regions, we can see that any value in
-    #  region 0 will be greater than 1 and 2 ---> 7 >= 7, 5, 0
-    #  region 1 values will be greater than 2 ---> 5 >= 3, 0
-    #  region 2 values are the end ----> 2 >= 0
-    #  this concept becomes more relevant when two non equi conditions
-    #  are present ---> l1 < r1 & l2 > r2
-    #  For two non equi conditions, the matches are where
-    #  the regions from group A (l1 < r1)
-    #  are also lower than the regions from group B (l2 < r2)
-    #  This implementation is based on the algorithm outlined here:
-    #  https://www.scitepress.org/papers/2018/68268/68268.pdf
-    indices = _search_indices(left_c, right_c, strict, op_code)
-    left_region = np.empty(left_c.size, dtype=np.intp)
-    left_region[:] = -1
-    max_indices = indices.max() + 1
-    if max_indices < left_index.size:
-        left_region = left_region[:max_indices]
-        left_index = left_index[:max_indices]
-    mask = indices == -1
-    if mask.all():
-        return None
-    if mask.any():
-        right_index = right_index[~mask]
-        indices = indices[~mask]
-    left_region[indices] = 1
-    mask = left_region == 1
-    count_unique_indices = np.bincount(indices)
-    count_unique_indices = np.count_nonzero(count_unique_indices)
-    left_region[mask] = np.arange(count_unique_indices)
-    start = left_region[-1]
-    arr = np.arange(left_region.size)[::-1]
-    for num in arr:
-        if left_region[num] != -1:
-            start = left_region[num]
-        else:
-            left_region[num] = start
-    right_region = left_region[indices]
-    return left_index, right_index, left_region, right_region
-
-
-@njit(parallel=True)
-def _search_indices(
-    left_c: np.ndarray, right_c: np.ndarray, strict: int, op_code: int
-) -> np.ndarray:
-    """
-    Get search indices for non-equi joins
-    """
-    indices = np.empty(right_c.size, dtype=np.intp)
-    for num in prange(right_c.size):
-        value = right_c[num]
-        if strict:
-            high = _searchsorted_left(left_c, value, op_code)
-        else:
-            high = _searchsorted_right(left_c, value, op_code)
-        indices[num] = high
-
-    return indices
-
-
-# the binary search functions below are modifications
-# of python's bisect function, with a simple aim of
-# getting regions.
-# region should always be at the far end left or right
-# e.g for [2, 3, 3, 4], 3 (if <) should be position 0
-# if (<=), position should be 1
-# for [4, 3, 2, 2], 3 (if >) should be position 0
-# if >=, position should be 1
-@njit()
-def _searchsorted_left(arr: np.ndarray, value: int, op_code: int) -> int:
-    """
-    Modification of Python's bisect_left function.
-    Used to get the relevant region
-    within the _get_regions function.
-    """
-    high = len(arr)
-    low = 0
-    while low < high:
-        mid = (low + high) // 2
-        if op_code:
-            check = arr[mid] < value
-        else:
-            check = arr[mid] > value
-        if check:
-            low = mid + 1
-        else:
-            high = mid
-    return high - 1
-
-
-@njit()
-def _searchsorted_right(arr: np.ndarray, value: int, op_code: int) -> int:
-    """
-    Modification of Python's bisect_right function.
-    Used to get the relevant region
-    within the _get_regions function.
-    """
-    high = len(arr)
-    low = 0
-    while low < high:
-        mid = (low + high) // 2
-        if op_code:
-            check = value < arr[mid]
-        else:
-            check = value > arr[mid]
-        if check:
-            high = mid
-        else:
-            low = mid + 1
-    return low - 1
diff --git a/janitor/functions/conditional_join.py b/janitor/functions/conditional_join.py
--- a/janitor/functions/conditional_join.py
+++ b/janitor/functions/conditional_join.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 import operator
-from enum import Enum
 from typing import Union, Any, Optional, Hashable, Literal
 import numpy as np
 import pandas as pd
@@ -17,7 +16,14 @@
 from pandas.core.reshape.merge import _MergeOperation
 
 from janitor.utils import check, check_column
-from janitor.functions.utils import _convert_to_numpy_array
+from janitor.functions.utils import (
+    _convert_to_numpy_array,
+    _JoinOperator,
+    _generic_func_cond_join,
+    _keep_output,
+    less_than_join_types,
+    greater_than_join_types,
+)
 
 
 @pf.register_dataframe_method
@@ -27,10 +33,11 @@ def conditional_join(
     *conditions: Any,
     how: Literal["inner", "left", "right"] = "inner",
     sort_by_appearance: bool = False,
-    df_columns: Optional[Any] = None,
-    right_columns: Optional[Any] = None,
+    df_columns: Optional[Any] = slice(None),
+    right_columns: Optional[Any] = slice(None),
     keep: Literal["first", "last", "all"] = "all",
     use_numba: bool = False,
+    indicator: Optional[bool, str] = False,
 ) -> pd.DataFrame:
     """The conditional_join function operates similarly to `pd.merge`,
     but allows joins on inequality operators,
@@ -47,8 +54,9 @@ def conditional_join(
     Column selection in `df_columns` and `right_columns` is possible using the
     [`select_columns`][janitor.functions.select.select_columns] syntax.
 
-    For strictly non-equi joins,
+    For strictly non-equi joins, particularly range joins,
     involving either `>`, `<`, `>=`, `<=` operators,
+    where the columns on the right are not both monotonically increasing,
     performance could be improved by setting `use_numba` to `True`.
     This assumes that `numba` is installed.
 
@@ -65,7 +73,6 @@ def conditional_join(
     The operator can be any of `==`, `!=`, `<=`, `<`, `>=`, `>`.
 
     The join is done only on the columns.
-    MultiIndex columns are not supported.
 
     For non-equi joins, only numeric and date columns are supported.
 
@@ -116,11 +123,13 @@ def conditional_join(
 
         - 0.24.0
             - Added `df_columns`, `right_columns`, `keep` and `use_numba` parameters.
+        - 0.24.1
+            - Added `indicator` parameter.
 
     Args:
         df: A pandas DataFrame.
         right: Named Series or DataFrame to join to.
-        *conditions: Variable argument of tuple(s) of the form
+        conditions: Variable argument of tuple(s) of the form
             `(left_on, right_on, op)`, where `left_on` is the column
             label from `df`, `right_on` is the column label from `right`,
             while `op` is the operator. The operator can be any of
@@ -130,7 +139,8 @@ def conditional_join(
         how: Indicates the type of join to be performed.
             It can be one of `inner`, `left`, `right`.
             Full outer join is not supported. Defaults to `inner`.
-        sort_by_appearance: This is useful for scenarios where the user wants
+        sort_by_appearance: Default is `False`.
+            This is useful for scenarios where the user wants
             the original order maintained.
             If `True` and `how = left`, the row order from the left dataframe
             is preserved; if `True` and `how = right`, the row order
@@ -142,9 +152,17 @@ def conditional_join(
             It can be a single column or a list of columns.
             It is also possible to rename the output columns via a dictionary.
         keep: Choose whether to return the first match,
-            last match or all matches.
+            last match or all matches. Default is `all`.
         use_numba: Use numba, if installed, to accelerate the computation.
-            Applicable only to strictly non-equi joins.
+            Applicable only to strictly non-equi joins. Default is `False`.
+        indicator: If `True`, adds a column to the output DataFrame
+            called “_merge” with information on the source of each row.
+            The column can be given a different name by providing a string argument.
+            The column will have a Categorical type with the value of “left_only”
+            for observations whose merge key only appears in the left DataFrame,
+            “right_only” for observations whose merge key
+            only appears in the right DataFrame, and “both” if the observation’s
+            merge key is found in both DataFrames.
 
     Returns:
         A pandas DataFrame of the two merged Pandas objects.
@@ -160,42 +178,10 @@ def conditional_join(
         right_columns,
         keep,
         use_numba,
+        indicator,
     )
 
 
-class _JoinOperator(Enum):
-    """
-    List of operators used in conditional_join.
-    """
-
-    GREATER_THAN = ">"
-    LESS_THAN = "<"
-    GREATER_THAN_OR_EQUAL = ">="
-    LESS_THAN_OR_EQUAL = "<="
-    STRICTLY_EQUAL = "=="
-    NOT_EQUAL = "!="
-
-
-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,
-}
-
-
-less_than_join_types = {
-    _JoinOperator.LESS_THAN.value,
-    _JoinOperator.LESS_THAN_OR_EQUAL.value,
-}
-greater_than_join_types = {
-    _JoinOperator.GREATER_THAN.value,
-    _JoinOperator.GREATER_THAN_OR_EQUAL.value,
-}
-
-
 def _check_operator(op: str):
     """
     Check that operator is one of
@@ -221,6 +207,7 @@ def _conditional_join_preliminary_checks(
     right_columns: Any,
     keep: str,
     use_numba: bool,
+    indicator: Union[bool, str],
 ) -> tuple:
     """
     Preliminary checks for conditional_join are conducted here.
@@ -297,6 +284,8 @@ def _conditional_join_preliminary_checks(
 
     check("use_numba", use_numba, [bool])
 
+    check("indicator", indicator, [bool, str])
+
     return (
         df,
         right,
@@ -307,6 +296,7 @@ def _conditional_join_preliminary_checks(
         right_columns,
         keep,
         use_numba,
+        indicator,
     )
 
 
@@ -376,6 +366,7 @@ def _conditional_join_compute(
     right_columns: Any,
     keep: str,
     use_numba: bool,
+    indicator: Union[bool, str],
 ) -> pd.DataFrame:
     """
     This is where the actual computation
@@ -393,6 +384,7 @@ def _conditional_join_compute(
         right_columns,
         keep,
         use_numba,
+        indicator,
     ) = _conditional_join_preliminary_checks(
         df,
         right,
@@ -403,6 +395,7 @@ def _conditional_join_compute(
         right_columns,
         keep,
         use_numba,
+        indicator,
     )
 
     eq_check = False
@@ -426,19 +419,26 @@ def _conditional_join_compute(
                 df, right, conditions, keep, use_numba
             )
         else:
-            result = _multiple_conditional_join_ne(
-                df, right, conditions, keep, use_numba
-            )
+            result = _multiple_conditional_join_ne(df, right, conditions, keep)
     else:
         left_on, right_on, op = conditions[0]
-        result = _generic_func_cond_join(
-            df[left_on],
-            right[right_on],
-            op,
-            False,
-            keep,
-            use_numba,
-        )
+        if use_numba:
+            from janitor.functions._numba import _numba_single_join
+
+            result = _numba_single_join(
+                left=df[left_on],
+                right=right[right_on],
+                op=op,
+                keep=keep,
+            )
+        else:
+            result = _generic_func_cond_join(
+                left=df[left_on],
+                right=right[right_on],
+                op=op,
+                multiple_conditions=False,
+                keep=keep,
+            )
 
     if result is None:
         result = np.array([], dtype=np.intp), np.array([], dtype=np.intp)
@@ -451,348 +451,18 @@ def _conditional_join_compute(
         sort_by_appearance,
         df_columns,
         right_columns,
+        indicator,
     )
 
 
-def _keep_output(keep: str, left: np.ndarray, right: np.ndarray):
-    """return indices for left and right index based on the value of `keep`."""
-    if keep == "all":
-        return left, right
-    grouped = pd.Series(right).groupby(left)
-    if keep == "first":
-        grouped = grouped.min()
-        return grouped.index, grouped.array
-    grouped = grouped.max()
-    return grouped.index, grouped.array
-
-
-def _less_than_indices(
-    left: pd.Series,
-    right: pd.Series,
-    strict: bool,
-    keep: str,
-) -> tuple:
-    """
-    Use binary search to get indices where left
-    is less than or equal to right.
-
-    If strict is True, then only indices
-    where `left` is less than
-    (but not equal to) `right` are returned.
-
-    A tuple of integer indexes
-    for left and right is returned.
-    """
-
-    # no point going through all the hassle
-    if left.min() > right.max():
-        return None
-
-    any_nulls = left.isna()
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        left = left[~any_nulls]
-    any_nulls = right.isna()
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        right = right[~any_nulls]
-    any_nulls = any_nulls.any()
-    right_is_sorted = right.is_monotonic_increasing
-    if not right_is_sorted:
-        right = right.sort_values(kind="stable")
-
-    left_index = left.index._values
-    left = left._values
-    right_index = right.index._values
-    right = right._values
-
-    search_indices = right.searchsorted(left, side="left")
-
-    # if any of the positions in `search_indices`
-    # is equal to the length of `right_keys`
-    # that means the respective position in `left`
-    # has no values from `right` that are less than
-    # or equal, and should therefore be discarded
-    len_right = right.size
-    rows_equal = search_indices == len_right
-
-    if rows_equal.any():
-        left = left[~rows_equal]
-        left_index = left_index[~rows_equal]
-        search_indices = search_indices[~rows_equal]
-
-    # the idea here is that if there are any equal values
-    # shift to the right to the immediate next position
-    # that is not equal
-    if strict:
-        rows_equal = right[search_indices]
-        rows_equal = left == 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 -> [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.
-        if rows_equal.any():
-            replacements = right.searchsorted(left, side="right")
-            # now we can safely replace values
-            # with strictly less than positions
-            search_indices = np.where(rows_equal, replacements, search_indices)
-        # check again if any of the values
-        # have become equal to length of right
-        # and get rid of them
-        rows_equal = search_indices == len_right
-
-        if rows_equal.any():
-            left = left[~rows_equal]
-            left_index = left_index[~rows_equal]
-            search_indices = search_indices[~rows_equal]
-
-        if not search_indices.size:
-            return None
-    if right_is_sorted and (keep == "first"):
-        if any_nulls:
-            return left_index, right_index[search_indices]
-        return left_index, search_indices
-    right = [right_index[ind:len_right] for ind in search_indices]
-    if keep == "first":
-        right = [arr.min() for arr in right]
-        return left_index, right
-    if keep == "last":
-        right = [arr.max() for arr in right]
-        return left_index, right
-    right = np.concatenate(right)
-    left = np.repeat(left_index, len_right - search_indices)
-    return left, right
-
-
-def _greater_than_indices(
-    left: pd.Series,
-    right: pd.Series,
-    strict: bool,
-    multiple_conditions: bool,
-    keep: str,
-) -> tuple:
-    """
-    Use binary search to get indices where left
-    is greater than or equal to right.
-
-    If strict is True, then only indices
-    where `left` is greater than
-    (but not equal to) `right` are returned.
-
-    if multiple_conditions is False, a tuple of integer indexes
-    for left and right is returned;
-    else a tuple of the index for left, right, as well
-    as the positions of left in right is returned.
-    """
-
-    # quick break, avoiding the hassle
-    if left.max() < right.min():
-        return None
-
-    any_nulls = left.isna()
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        left = left[~any_nulls]
-    any_nulls = right.isna()
-    if any_nulls.all():
-        return None
-    if any_nulls.any():
-        right = right[~any_nulls]
-    any_nulls = any_nulls.any()
-    right_is_sorted = right.is_monotonic_increasing
-    if not right_is_sorted:
-        right = right.sort_values(kind="stable")
-
-    left_index = left.index._values
-    left = left._values
-    right_index = right.index._values
-    right = right._values
-
-    search_indices = right.searchsorted(left, side="right")
-    # if any of the positions in `search_indices`
-    # is equal to 0 (less than 1), it implies that
-    # left[position] is not greater than any value
-    # in right
-    rows_equal = search_indices < 1
-    if rows_equal.any():
-        left = left[~rows_equal]
-        left_index = left_index[~rows_equal]
-        search_indices = search_indices[~rows_equal]
-
-    # the idea here is that if there are any equal values
-    # shift downwards to the immediate next position
-    # that is not equal
-    if strict:
-        rows_equal = right[search_indices - 1]
-        rows_equal = left == rows_equal
-        # replace positions where rows are equal with
-        # searchsorted('left');
-        # however there can be scenarios where positions
-        # from searchsorted('left') would still be equal;
-        # in that case, we shift down by 1
-        if rows_equal.any():
-            replacements = right.searchsorted(left, side="left")
-            # return replacements
-            # `left` might result in values equal to len right
-            replacements = np.where(
-                replacements == right.size, replacements - 1, replacements
-            )
-            # now we can safely replace values
-            # with strictly greater than positions
-            search_indices = np.where(rows_equal, replacements, search_indices)
-        # any value less than 1 should be discarded
-        # since the lowest value for binary search
-        # with side='right' should be 1
-        rows_equal = search_indices < 1
-        if rows_equal.any():
-            left = left[~rows_equal]
-            left_index = left_index[~rows_equal]
-            search_indices = search_indices[~rows_equal]
-
-        if not search_indices.size:
-            return None
-
-    if multiple_conditions:
-        return left_index, right_index, search_indices
-    if right_is_sorted and (keep == "last"):
-        if any_nulls:
-            return left_index, right_index[search_indices - 1]
-        return left_index, search_indices - 1
-    right = [right_index[:ind] for ind in search_indices]
-    if keep == "first":
-        right = [arr.min() for arr in right]
-        return left_index, right
-    if keep == "last":
-        right = [arr.max() for arr in right]
-        return left_index, right
-    right = np.concatenate(right)
-    left = np.repeat(left_index, search_indices)
-    return left, right
-
-
-def _not_equal_indices(left: pd.Series, right: pd.Series, keep: str) -> tuple:
-    """
-    Use binary search to get indices where
-    `left` is exactly  not equal to `right`.
-
-    It is a combination of strictly less than
-    and strictly greater than indices.
-
-    A tuple of integer indexes for left and right
-    is returned.
-    """
-
-    dummy = np.array([], dtype=int)
-
-    # deal with nulls
-    l1_nulls = dummy
-    r1_nulls = dummy
-    l2_nulls = dummy
-    r2_nulls = dummy
-    any_left_nulls = left.isna()
-    any_right_nulls = right.isna()
-    if any_left_nulls.any():
-        l1_nulls = left.index[any_left_nulls.array]
-        l1_nulls = l1_nulls.to_numpy(copy=False)
-        r1_nulls = right.index
-        # avoid NAN duplicates
-        if any_right_nulls.any():
-            r1_nulls = r1_nulls[~any_right_nulls.array]
-        r1_nulls = r1_nulls.to_numpy(copy=False)
-        nulls_count = l1_nulls.size
-        # blow up nulls to match length of right
-        l1_nulls = np.tile(l1_nulls, r1_nulls.size)
-        # ensure length of right matches left
-        if nulls_count > 1:
-            r1_nulls = np.repeat(r1_nulls, nulls_count)
-    if any_right_nulls.any():
-        r2_nulls = right.index[any_right_nulls.array]
-        r2_nulls = r2_nulls.to_numpy(copy=False)
-        l2_nulls = left.index
-        nulls_count = r2_nulls.size
-        # blow up nulls to match length of left
-        r2_nulls = np.tile(r2_nulls, l2_nulls.size)
-        # ensure length of left matches right
-        if nulls_count > 1:
-            l2_nulls = np.repeat(l2_nulls, nulls_count)
-
-    l1_nulls = np.concatenate([l1_nulls, l2_nulls])
-    r1_nulls = np.concatenate([r1_nulls, r2_nulls])
-
-    outcome = _less_than_indices(left, right, strict=True, keep=keep)
-
-    if outcome is None:
-        lt_left = dummy
-        lt_right = dummy
-    else:
-        lt_left, lt_right = outcome
-
-    outcome = _greater_than_indices(
-        left, right, strict=True, multiple_conditions=False, keep=keep
-    )
-
-    if outcome is None:
-        gt_left = dummy
-        gt_right = dummy
-    else:
-        gt_left, gt_right = outcome
-
-    left = np.concatenate([lt_left, gt_left, l1_nulls])
-    right = np.concatenate([lt_right, gt_right, r1_nulls])
-
-    if (not left.size) & (not right.size):
-        return None
-    return _keep_output(keep, left, right)
-
-
-def _generic_func_cond_join(
-    left: pd.Series,
-    right: pd.Series,
-    op: str,
-    multiple_conditions: bool,
-    keep: str,
-    use_numba: bool,
-) -> tuple:
-    """
-    Generic function to call any of the individual functions
-    (_less_than_indices, _greater_than_indices,
-    or _not_equal_indices).
-    """
-    strict = False
-
-    if op in {
-        _JoinOperator.GREATER_THAN.value,
-        _JoinOperator.LESS_THAN.value,
-        _JoinOperator.NOT_EQUAL.value,
-    }:
-        strict = True
-
-    if use_numba:
-        if op in less_than_join_types:
-            op_code = 1
-        elif op in greater_than_join_types:
-            op_code = 0
-        else:
-            op_code = -1
-        from janitor.functions._numba import _numba_single_join
-
-        return _numba_single_join(left, right, strict, keep, op_code)
-
-    if op in less_than_join_types:
-        return _less_than_indices(left, right, strict, keep)
-    if op in greater_than_join_types:
-        return _greater_than_indices(
-            left, right, strict, multiple_conditions, keep
-        )
-    if op == _JoinOperator.NOT_EQUAL.value:
-        return _not_equal_indices(left, right, keep)
+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 _generate_indices(
@@ -831,7 +501,6 @@ def _multiple_conditional_join_ne(
     right: pd.DataFrame,
     conditions: list[tuple[pd.Series, pd.Series, str]],
     keep: str,
-    use_numba: bool,
 ) -> tuple:
     """
     Get indices for multiple conditions,
@@ -844,21 +513,15 @@ def _multiple_conditional_join_ne(
     # not equal typically combines less than
     # and greater than, so a lot more rows are returned
     # than just less than or greater than
-
-    # here we get indices for the first condition in conditions
-    # then use those indices to get the final indices,
-    # using _generate_indices
     first, *rest = conditions
     left_on, right_on, op = first
 
-    # get indices from the first condition
     indices = _generic_func_cond_join(
         df[left_on],
         right[right_on],
         op,
         multiple_conditions=False,
         keep="all",
-        use_numba=use_numba,
     )
     if indices is None:
         return None
@@ -934,7 +597,7 @@ def _multiple_conditional_join_le_lt(
     Returns a tuple of (df_index, right_index)
     """
     if use_numba:
-        from janitor.functions._numba import _numba_pair_le_lt
+        from janitor.functions._numba import _numba_dual_join
 
         pairs = [
             condition
@@ -977,9 +640,7 @@ def _multiple_conditional_join_le_lt(
                 pairs.extend(patch)
                 first, middle, last = pairs
                 pairs = [(first, middle), (first, last)]
-                indices = [
-                    _numba_pair_le_lt(df, right, pair) for pair in pairs
-                ]
+                indices = [_numba_dual_join(df, right, pair) for pair in pairs]
                 indices = [arr for arr in indices if arr is not None]
                 if not indices:
                     indices = None
@@ -997,10 +658,9 @@ def _multiple_conditional_join_le_lt(
                     op,
                     multiple_conditions=False,
                     keep="all",
-                    use_numba=True,
                 )
         else:
-            indices = _numba_pair_le_lt(df, right, pairs)
+            indices = _numba_dual_join(df, right, pairs)
     else:
         # there is an opportunity for optimization for range joins
         # which is usually `lower_value < value < upper_value`
@@ -1081,7 +741,6 @@ def _multiple_conditional_join_le_lt(
                 op,
                 multiple_conditions=False,
                 keep="all",
-                use_numba=False,
             )
 
     if not indices:
@@ -1125,7 +784,8 @@ def _range_indices(
     right_c = right[right_on]
     left_on, right_on, _ = second
     # get rid of any nulls
-    # this is helpful as we can convert extension arrays to numpy arrays safely
+    # this is helpful as we can convert extension arrays
+    # to numpy arrays safely
     # and simplify the search logic below
     any_nulls = df[left_on].isna()
     if any_nulls.any():
@@ -1134,14 +794,10 @@ def _range_indices(
     if any_nulls.any():
         right_c = right_c[~any_nulls]
 
-    strict = False
-    if op == _JoinOperator.GREATER_THAN.value:
-        strict = True
-
-    outcome = _greater_than_indices(
-        left_c,
-        right_c,
-        strict,
+    outcome = _generic_func_cond_join(
+        left=left_c,
+        right=right_c,
+        op=op,
         multiple_conditions=True,
         keep="all",
     )
@@ -1153,31 +809,48 @@ def _range_indices(
     left_on, right_on, op = second
     right_c = right.loc[right_index, right_on]
     left_c = df.loc[left_index, left_on]
-
-    left_c = left_c._values
-    right_c = right_c._values
-    left_c, right_c = _convert_to_numpy_array(left_c, right_c)
-    op = operator_map[op]
-    pos = np.copy(search_indices)
-    counter = np.arange(left_c.size)
-
-    # better than np.outer memory wise?
-    # using this for loop instead of np.outer
-    # allows us to break early and reduce the
-    # number of cartesian checks
-    # since as we iterate, we reduce the size of left_c
-    # speed wise, np.outer will be faster
-    # alternatively, the user can just use the numba option
-    # for more performance
-    for ind in range(right_c.size):
-        if not counter.size:
-            break
-        keep_rows = op(left_c, right_c[ind])
-        if not keep_rows.any():
-            continue
-        pos[counter[keep_rows]] = ind
-        counter = counter[~keep_rows]
-        left_c = left_c[~keep_rows]
+    # if True, we can use a binary search
+    # for more performance, instead of a linear search
+    fastpath = right_c.is_monotonic_increasing
+    if fastpath:
+        outcome = _generic_func_cond_join(
+            left=left_c,
+            right=right_c,
+            op=op,
+            multiple_conditions=False,
+            keep="first",
+        )
+        if outcome is None:
+            return None
+        left_c, pos = outcome
+        if left_c.size < left_index.size:
+            keep_rows = np.isin(left_index, left_c, assume_unique=True)
+            search_indices = search_indices[keep_rows]
+            left_index = left_c
+    else:
+        left_c = left_c._values
+        right_c = right_c._values
+        op = operator_map[op]
+        left_c, right_c = _convert_to_numpy_array(left_c, right_c)
+        pos = np.copy(search_indices)
+        counter = np.arange(left_c.size)
+        # better than np.outer memory wise?
+        # using this for loop instead of np.outer
+        # allows us to break early and reduce the
+        # number of cartesian checks
+        # since as we iterate, we reduce the size of left_c
+        # speed wise, np.outer will be faster
+        # alternatively, the user can just use the numba option
+        # for more performance
+        for ind in range(right_c.size):
+            if not counter.size:
+                break
+            keep_rows = op(left_c, right_c[ind])
+            if not keep_rows.any():
+                continue
+            pos[counter[keep_rows]] = ind
+            counter = counter[~keep_rows]
+            left_c = left_c[~keep_rows]
 
     # no point searching within (a, b)
     # if a == b
@@ -1193,12 +866,22 @@ def _range_indices(
         search_indices = search_indices[keep_rows]
 
     repeater = search_indices - pos
+    if (repeater == 1).all():
+        # no point running a comparison op
+        # if the width is all 1
+        # this also implies that the intervals
+        # do not overlap on the right side
+        return left_index, right_index[pos]
+
     right_index = [
         right_index[start:end] for start, end in zip(pos, search_indices)
     ]
 
     right_index = np.concatenate(right_index)
     left_index = np.repeat(left_index, repeater)
+
+    if fastpath:
+        return left_index, right_index
     # here we search for actual positions
     # where left_c is </<= right_c
     # safe to index the arrays, since we are picking the positions
@@ -1208,7 +891,6 @@ def _range_indices(
     left_c = df[left_on]._values[left_index]
     right_c = right[right_on]._values[right_index]
     ext_arr = is_extension_array_dtype(left_c)
-
     mask = op(left_c, right_c)
 
     if ext_arr:
@@ -1265,68 +947,108 @@ def _create_frame(
     sort_by_appearance: bool,
     df_columns: Any,
     right_columns: Any,
+    indicator: Union[bool, str],
 ):
     """
     Create final dataframe
     """
-    if df_columns is not None:
+    if (df_columns is None) and (right_columns is None):
+        raise ValueError("df_columns and right_columns cannot both be None.")
+    if (df_columns is not None) and (df_columns != slice(None)):
         df = _cond_join_select_columns(df_columns, df)
-
-    if right_columns is not None:
+    if (right_columns is not None) and (right_columns != slice(None)):
         right = _cond_join_select_columns(right_columns, right)
+    if df_columns is None:
+        df = pd.DataFrame([])
+    elif right_columns is None:
+        right = pd.DataFrame([])
 
-    if set(df.columns).intersection(right.columns):
+    if not df.columns.intersection(right.columns).empty:
         df, right = _create_multiindex_column(df, right)
 
-    if sort_by_appearance or (left_index.size == 0):
+    if indicator:
+        if isinstance(indicator, bool):
+            indicator = "_merge"
+        if indicator in df.columns.union(right.columns):
+            raise ValueError(
+                "Cannot use name of an existing column for indicator column"
+            )
+
+    if sort_by_appearance:
         if how in {"inner", "left"}:
-            right = right.take(right_index)
+            if not right.empty:
+                right = right.take(right_index)
             right.index = left_index
         else:
-            df = df.take(left_index)
+            if not df.empty:
+                df = df.take(left_index)
             df.index = right_index
-        df = pd.merge(
-            df,
-            right,
-            left_index=True,
-            right_index=True,
-            sort=False,
-            copy=False,
-            how=how,
-        )
+        df = df.join(right, how=how)
+
+        if indicator:
+            if how == "right":
+                df.loc[right_index, indicator] = 3
+                df[indicator] = df[indicator].fillna(2)
+            else:
+                df.loc[left_index, indicator] = 3
+                df[indicator] = df[indicator].fillna(1)
+            df[indicator] = pd.Categorical(df[indicator], categories=[1, 2, 3])
+            df[indicator] = df[indicator].cat.rename_categories(
+                ["left_only", "right_only", "both"]
+            )
         df.index = range(len(df))
         return df
 
-    def _inner(
-        df: pd.DataFrame,
-        right: pd.DataFrame,
-        left_index: pd.DataFrame,
-        right_index: pd.DataFrame,
-    ) -> pd.DataFrame:
-        """Create DataFrame for inner join"""
-        df = {key: value._values[left_index] for key, value in df.items()}
-        right = {
-            key: value._values[right_index] for key, value in right.items()
-        }
-        df.update(right)
-        return pd.DataFrame(df, copy=False)
-
     if how == "inner":
-        return _inner(df, right, left_index, right_index)
+        return _inner(df, right, left_index, right_index, indicator)
 
     if how == "left":
-        df_ = np.bincount(left_index, minlength=df.index.size) == 0
-        df_ = df_.nonzero()[0]
-        if not df_.size:
-            return _inner(df, right, left_index, right_index)
-        df_ = df.take(df_)
-        df = _inner(df, right, left_index, right_index)
-        return pd.concat([df, df_], ignore_index=True)
-    if how == "right":
-        right_ = np.bincount(right_index, minlength=right.index.size) == 0
-        right_ = right_.nonzero()[0]
-        if not right_.size:
-            return _inner(df, right, left_index, right_index)
-        right_ = right.take(right_)
-        right = _inner(df, right, left_index, right_index)
-        return pd.concat([right, right_], ignore_index=True)
+        arr = df
+        arr_ = np.delete(df.index._values, left_index)
+    else:
+        arr = right
+        arr_ = np.delete(right.index._values, right_index)
+    if not arr_.size:
+        return _inner(df, right, left_index, right_index, indicator)
+    if indicator:
+        length = arr_.size
+    arr_ = {key: value._values[arr_] for key, value in arr.items()}
+    if indicator:
+        arr_ = _add_indicator(
+            df=arr_, indicator=indicator, how=how, length=length
+        )
+    arr_ = pd.DataFrame(arr_, copy=False)
+    arr = _inner(df, right, left_index, right_index, indicator)
+    df = pd.concat([arr, arr_], copy=False, sort=False)
+    return df
+
+
+def _add_indicator(
+    df: dict, indicator: str, how: str, length: int
+) -> pd.DataFrame:
+    "Add indicator column to the DataFrame"
+    mapping = {"inner": "both", "left": "left_only", "right": "right_only"}
+    arr = pd.Categorical(
+        [mapping[how]], categories=["left_only", "right_only", "both"]
+    )
+    if isinstance(next(iter(df)), tuple):
+        indicator = (indicator, "")
+    df[indicator] = arr.repeat(length)
+    return df
+
+
+def _inner(
+    df: pd.DataFrame,
+    right: pd.DataFrame,
+    left_index: pd.DataFrame,
+    right_index: pd.DataFrame,
+    indicator: Union[bool, str],
+) -> pd.DataFrame:
+    """Create DataFrame for inner join"""
+
+    df = {key: value._values[left_index] for key, value in df.items()}
+    right = {key: value._values[right_index] for key, value in right.items()}
+    df.update(right)
+    if indicator:
+        df = _add_indicator(df, indicator, "inner", left_index.size)
+    return pd.DataFrame(df, copy=False)
diff --git a/janitor/functions/utils.py b/janitor/functions/utils.py
--- a/janitor/functions/utils.py
+++ b/janitor/functions/utils.py
@@ -19,7 +19,7 @@
 from pandas.core.dtypes.generic import ABCPandasArray, ABCExtensionArray
 from pandas.core.common import is_bool_indexer
 from dataclasses import dataclass
-
+from enum import Enum
 import pandas as pd
 from janitor.utils import check, _expand_grid
 from pandas.api.types import (
@@ -661,3 +661,366 @@ def _convert_to_numpy_array(
         left = left.to_numpy(copy=False)
         right = right.to_numpy(copy=False)
     return left, right
+
+
+class _JoinOperator(Enum):
+    """
+    List of operators used in conditional_join.
+    """
+
+    GREATER_THAN = ">"
+    LESS_THAN = "<"
+    GREATER_THAN_OR_EQUAL = ">="
+    LESS_THAN_OR_EQUAL = "<="
+    STRICTLY_EQUAL = "=="
+    NOT_EQUAL = "!="
+
+
+less_than_join_types = {
+    _JoinOperator.LESS_THAN.value,
+    _JoinOperator.LESS_THAN_OR_EQUAL.value,
+}
+greater_than_join_types = {
+    _JoinOperator.GREATER_THAN.value,
+    _JoinOperator.GREATER_THAN_OR_EQUAL.value,
+}
+
+
+def _less_than_indices(
+    left: pd.Series,
+    right: pd.Series,
+    strict: bool,
+    multiple_conditions: bool,
+    keep: str,
+) -> tuple:
+    """
+    Use binary search to get indices where left
+    is less than or equal to right.
+
+    If strict is True, then only indices
+    where `left` is less than
+    (but not equal to) `right` are returned.
+
+    A tuple of integer indexes
+    for left and right is returned.
+    """
+
+    # no point going through all the hassle
+    if left.min() > right.max():
+        return None
+
+    any_nulls = left.isna()
+    if any_nulls.all():
+        return None
+    if any_nulls.any():
+        left = left[~any_nulls]
+    any_nulls = right.isna()
+    if any_nulls.all():
+        return None
+    if any_nulls.any():
+        right = right[~any_nulls]
+    any_nulls = any_nulls.any()
+    right_is_sorted = right.is_monotonic_increasing
+    if not right_is_sorted:
+        right = right.sort_values(kind="stable")
+
+    left_index = left.index._values
+    left = left._values
+    right_index = right.index._values
+    right = right._values
+
+    search_indices = right.searchsorted(left, side="left")
+
+    # if any of the positions in `search_indices`
+    # is equal to the length of `right_keys`
+    # that means the respective position in `left`
+    # has no values from `right` that are less than
+    # or equal, and should therefore be discarded
+    len_right = right.size
+    rows_equal = search_indices == len_right
+
+    if rows_equal.any():
+        left = left[~rows_equal]
+        left_index = left_index[~rows_equal]
+        search_indices = search_indices[~rows_equal]
+
+    # the idea here is that if there are any equal values
+    # shift to the right to the immediate next position
+    # that is not equal
+    if strict:
+        rows_equal = right[search_indices]
+        rows_equal = left == 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 -> [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.
+        if rows_equal.any():
+            replacements = right.searchsorted(left, side="right")
+            # now we can safely replace values
+            # with strictly less than positions
+            search_indices = np.where(rows_equal, replacements, search_indices)
+        # check again if any of the values
+        # have become equal to length of right
+        # and get rid of them
+        rows_equal = search_indices == len_right
+
+        if rows_equal.any():
+            left = left[~rows_equal]
+            left_index = left_index[~rows_equal]
+            search_indices = search_indices[~rows_equal]
+
+        if not search_indices.size:
+            return None
+
+    if multiple_conditions:
+        return left_index, right_index, search_indices
+    if right_is_sorted and (keep == "first"):
+        if any_nulls:
+            return left_index, right_index[search_indices]
+        return left_index, search_indices
+    right = [right_index[ind:len_right] for ind in search_indices]
+    if keep == "first":
+        right = [arr.min() for arr in right]
+        return left_index, right
+    if keep == "last":
+        right = [arr.max() for arr in right]
+        return left_index, right
+    right = np.concatenate(right)
+    left = np.repeat(left_index, len_right - search_indices)
+    return left, right
+
+
+def _greater_than_indices(
+    left: pd.Series,
+    right: pd.Series,
+    strict: bool,
+    multiple_conditions: bool,
+    keep: str,
+) -> tuple:
+    """
+    Use binary search to get indices where left
+    is greater than or equal to right.
+
+    If strict is True, then only indices
+    where `left` is greater than
+    (but not equal to) `right` are returned.
+
+    if multiple_conditions is False, a tuple of integer indexes
+    for left and right is returned;
+    else a tuple of the index for left, right, as well
+    as the positions of left in right is returned.
+    """
+
+    # quick break, avoiding the hassle
+    if left.max() < right.min():
+        return None
+
+    any_nulls = left.isna()
+    if any_nulls.all():
+        return None
+    if any_nulls.any():
+        left = left[~any_nulls]
+    any_nulls = right.isna()
+    if any_nulls.all():
+        return None
+    if any_nulls.any():
+        right = right[~any_nulls]
+    any_nulls = any_nulls.any()
+    right_is_sorted = right.is_monotonic_increasing
+    if not right_is_sorted:
+        right = right.sort_values(kind="stable")
+
+    left_index = left.index._values
+    left = left._values
+    right_index = right.index._values
+    right = right._values
+
+    search_indices = right.searchsorted(left, side="right")
+    # if any of the positions in `search_indices`
+    # is equal to 0 (less than 1), it implies that
+    # left[position] is not greater than any value
+    # in right
+    rows_equal = search_indices < 1
+    if rows_equal.any():
+        left = left[~rows_equal]
+        left_index = left_index[~rows_equal]
+        search_indices = search_indices[~rows_equal]
+
+    # the idea here is that if there are any equal values
+    # shift downwards to the immediate next position
+    # that is not equal
+    if strict:
+        rows_equal = right[search_indices - 1]
+        rows_equal = left == rows_equal
+        # replace positions where rows are equal with
+        # searchsorted('left');
+        # this works fine since we will be using the value
+        # as the right side of a slice, which is not included
+        # in the final computed value
+        if rows_equal.any():
+            replacements = right.searchsorted(left, side="left")
+            # now we can safely replace values
+            # with strictly greater than positions
+            search_indices = np.where(rows_equal, replacements, search_indices)
+        # any value less than 1 should be discarded
+        # since the lowest value for binary search
+        # with side='right' should be 1
+        rows_equal = search_indices < 1
+        if rows_equal.any():
+            left = left[~rows_equal]
+            left_index = left_index[~rows_equal]
+            search_indices = search_indices[~rows_equal]
+
+        if not search_indices.size:
+            return None
+
+    if multiple_conditions:
+        return left_index, right_index, search_indices
+    if right_is_sorted and (keep == "last"):
+        if any_nulls:
+            return left_index, right_index[search_indices - 1]
+        return left_index, search_indices - 1
+    right = [right_index[:ind] for ind in search_indices]
+    if keep == "first":
+        right = [arr.min() for arr in right]
+        return left_index, right
+    if keep == "last":
+        right = [arr.max() for arr in right]
+        return left_index, right
+    right = np.concatenate(right)
+    left = np.repeat(left_index, search_indices)
+    return left, right
+
+
+def _not_equal_indices(left: pd.Series, right: pd.Series, keep: str) -> tuple:
+    """
+    Use binary search to get indices where
+    `left` is exactly  not equal to `right`.
+
+    It is a combination of strictly less than
+    and strictly greater than indices.
+
+    A tuple of integer indexes for left and right
+    is returned.
+    """
+
+    dummy = np.array([], dtype=int)
+
+    # deal with nulls
+    l1_nulls = dummy
+    r1_nulls = dummy
+    l2_nulls = dummy
+    r2_nulls = dummy
+    any_left_nulls = left.isna()
+    any_right_nulls = right.isna()
+    if any_left_nulls.any():
+        l1_nulls = left.index[any_left_nulls.array]
+        l1_nulls = l1_nulls.to_numpy(copy=False)
+        r1_nulls = right.index
+        # avoid NAN duplicates
+        if any_right_nulls.any():
+            r1_nulls = r1_nulls[~any_right_nulls.array]
+        r1_nulls = r1_nulls.to_numpy(copy=False)
+        nulls_count = l1_nulls.size
+        # blow up nulls to match length of right
+        l1_nulls = np.tile(l1_nulls, r1_nulls.size)
+        # ensure length of right matches left
+        if nulls_count > 1:
+            r1_nulls = np.repeat(r1_nulls, nulls_count)
+    if any_right_nulls.any():
+        r2_nulls = right.index[any_right_nulls.array]
+        r2_nulls = r2_nulls.to_numpy(copy=False)
+        l2_nulls = left.index
+        nulls_count = r2_nulls.size
+        # blow up nulls to match length of left
+        r2_nulls = np.tile(r2_nulls, l2_nulls.size)
+        # ensure length of left matches right
+        if nulls_count > 1:
+            l2_nulls = np.repeat(l2_nulls, nulls_count)
+
+    l1_nulls = np.concatenate([l1_nulls, l2_nulls])
+    r1_nulls = np.concatenate([r1_nulls, r2_nulls])
+
+    outcome = _less_than_indices(
+        left, right, strict=True, multiple_conditions=False, keep=keep
+    )
+
+    if outcome is None:
+        lt_left = dummy
+        lt_right = dummy
+    else:
+        lt_left, lt_right = outcome
+
+    outcome = _greater_than_indices(
+        left, right, strict=True, multiple_conditions=False, keep=keep
+    )
+
+    if outcome is None:
+        gt_left = dummy
+        gt_right = dummy
+    else:
+        gt_left, gt_right = outcome
+
+    left = np.concatenate([lt_left, gt_left, l1_nulls])
+    right = np.concatenate([lt_right, gt_right, r1_nulls])
+
+    if (not left.size) & (not right.size):
+        return None
+    return _keep_output(keep, left, right)
+
+
+def _generic_func_cond_join(
+    left: pd.Series,
+    right: pd.Series,
+    op: str,
+    multiple_conditions: bool,
+    keep: str,
+) -> tuple:
+    """
+    Generic function to call any of the individual functions
+    (_less_than_indices, _greater_than_indices,
+    or _not_equal_indices).
+    """
+    strict = False
+
+    if op in {
+        _JoinOperator.GREATER_THAN.value,
+        _JoinOperator.LESS_THAN.value,
+        _JoinOperator.NOT_EQUAL.value,
+    }:
+        strict = True
+
+    if op in less_than_join_types:
+        return _less_than_indices(
+            left=left,
+            right=right,
+            strict=strict,
+            multiple_conditions=multiple_conditions,
+            keep=keep,
+        )
+    if op in greater_than_join_types:
+        return _greater_than_indices(
+            left=left,
+            right=right,
+            strict=strict,
+            multiple_conditions=multiple_conditions,
+            keep=keep,
+        )
+    if op == _JoinOperator.NOT_EQUAL.value:
+        return _not_equal_indices(left, right, keep)
+
+
+def _keep_output(keep: str, left: np.ndarray, right: np.ndarray):
+    """return indices for left and right index based on the value of `keep`."""
+    if keep == "all":
+        return left, right
+    grouped = pd.Series(right).groupby(left)
+    if keep == "first":
+        grouped = grouped.min()
+        return grouped.index, grouped.array
+    grouped = grouped.max()
+    return grouped.index, grouped.array


Test patch:
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
@@ -28,6 +28,17 @@ def series():
     return pd.Series([2, 3, 4], name="B")
 
 
+def test_df_columns_right_columns_both_None(dummy, series):
+    """Raise if both df_columns and right_columns is None"""
+    with pytest.raises(
+        ValueError,
+        match="df_columns and right_columns " "cannot both be None.",
+    ):
+        dummy.conditional_join(
+            series, ("id", "B", ">"), df_columns=None, right_columns=None
+        )
+
+
 def test_df_multiindex(dummy, series):
     """Raise ValueError if `df` columns is a MultiIndex."""
     with pytest.raises(
@@ -68,6 +79,21 @@ def test_check_condition_type(dummy, series):
         dummy.conditional_join(series, ("id", "B", ">"), ["A", "B"])
 
 
+def test_indicator_type(dummy, series):
+    """Raise TypeError if indicator is not a boolean/string."""
+    with pytest.raises(TypeError, match="indicator should be one of.+"):
+        dummy.conditional_join(series, ("id", "B", ">"), indicator=1)
+
+
+def test_indicator_exists(dummy, series):
+    """Raise ValueError if indicator is a dup of an existing column name."""
+    with pytest.raises(
+        ValueError,
+        match="Cannot use name of an " "existing column for indicator column",
+    ):
+        dummy.conditional_join(series, ("id", "B", ">"), indicator="id")
+
+
 def test_check_condition_length(dummy, series):
     """Raise ValueError if any condition is not length 3."""
     with pytest.raises(
@@ -1187,7 +1213,14 @@ def test_how_left(df, right):
     expected.index.name = None
     expected = (
         df[["A"]]
-        .join(expected[["Integers"]], how="left", sort=False)
+        .merge(
+            expected[["Integers"]],
+            left_index=True,
+            right_index=True,
+            how="left",
+            indicator=True,
+            sort=False,
+        )
         .sort_values(["A", "Integers"], ignore_index=True)
         .reset_index(drop=True)
     )
@@ -1197,6 +1230,93 @@ def test_how_left(df, right):
             right[["Integers"]],
             ("A", "Integers", "<="),
             how="left",
+            indicator=True,
+        )
+        .sort_values(["A", "Integers"], ignore_index=True)
+    )
+
+    assert_frame_equal(expected, actual)
+
+
+@pytest.mark.turtle
+@settings(deadline=None)
+@given(df=conditional_df(), right=conditional_right())
+def test_how_left_multiindex(df, right):
+    """Test output when `how==left`. "<="."""
+
+    expected = (
+        df[["A"]]
+        .assign(index=np.arange(len(df)))
+        .merge(right.Integers.rename("A"), how="cross")
+        .loc[lambda df: df.A_x <= df.A_y]
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = (
+        df[["A"]]
+        .merge(
+            expected[["A_y"]],
+            left_index=True,
+            right_index=True,
+            how="left",
+            indicator=True,
+            sort=False,
+        )
+        .sort_values(["A", "A_y"], ignore_index=True)
+        .reset_index(drop=True)
+    )
+    actual = (
+        df[["A"]]
+        .conditional_join(
+            right.Integers.rename("A"),
+            ("A", "A", "<="),
+            how="left",
+            indicator=True,
+        )
+        .collapse_levels()
+        .rename(columns={"left_A": "A", "right_A": "A_y"})
+        .select_columns("A", "A_y", "_merge")
+        .sort_values(["A", "A_y"], ignore_index=True)
+    )
+
+    assert_frame_equal(expected, actual)
+
+
+@pytest.mark.turtle
+@settings(deadline=None)
+@given(df=conditional_df(), right=conditional_right())
+def test_how_left_sort(df, right):
+    """Test output when `how==left`. "<="."""
+
+    expected = (
+        df[["A"]]
+        .assign(index=np.arange(len(df)))
+        .merge(right[["Integers"]], how="cross")
+        .loc[lambda df: df.A <= df.Integers]
+    )
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = (
+        df[["A"]]
+        .merge(
+            expected[["Integers"]],
+            left_index=True,
+            right_index=True,
+            how="left",
+            indicator=True,
+            sort=False,
+        )
+        .sort_values(["A", "Integers"], ignore_index=True)
+        .reset_index(drop=True)
+    )
+    actual = (
+        df[["A"]]
+        .conditional_join(
+            right[["Integers"]],
+            ("A", "Integers", "<="),
+            how="left",
+            indicator=True,
+            sort_by_appearance=True,
         )
         .sort_values(["A", "Integers"], ignore_index=True)
     )
@@ -1217,7 +1337,49 @@ def test_how_right(df, right):
     expected.index.name = None
     expected = (
         expected[["E"]]
-        .join(right[["Dates"]], how="right", sort=False)
+        .merge(
+            right[["Dates"]],
+            how="right",
+            left_index=True,
+            right_index=True,
+            sort=False,
+            indicator=True,
+        )
+        .sort_values(["E", "Dates"], ignore_index=True)
+        .reset_index(drop=True)
+    )
+    actual = (
+        df[["E"]]
+        .conditional_join(
+            right[["Dates"]], ("E", "Dates", ">"), how="right", indicator=True
+        )
+        .sort_values(["E", "Dates"], ignore_index=True)
+    )
+
+    assert_frame_equal(expected, actual)
+
+
+@settings(deadline=None)
+@given(df=conditional_df(), right=conditional_right())
+@pytest.mark.turtle
+def test_how_right_sort(df, right):
+    """Test output when `how==right`. ">"."""
+
+    expected = df.merge(
+        right.assign(index=np.arange(len(right))), how="cross"
+    ).loc[lambda df: df.E.gt(df.Dates)]
+    expected = expected.set_index("index")
+    expected.index.name = None
+    expected = (
+        expected[["E"]]
+        .merge(
+            right[["Dates"]],
+            how="right",
+            left_index=True,
+            right_index=True,
+            sort=False,
+            indicator=True,
+        )
         .sort_values(["E", "Dates"], ignore_index=True)
         .reset_index(drop=True)
     )
@@ -1227,6 +1389,8 @@ def test_how_right(df, right):
             right[["Dates"]],
             ("E", "Dates", ">"),
             how="right",
+            indicator=True,
+            sort_by_appearance=True,
         )
         .sort_values(["E", "Dates"], ignore_index=True)
     )
@@ -1527,7 +1691,14 @@ def test_dual_conditions_gt_and_lt_numbers_left_join(df, right):
     expected.index.name = None
     expected = (
         df[["B"]]
-        .join(expected[["Numeric", "Floats"]], how="left", sort=False)
+        .merge(
+            expected[["Numeric", "Floats"]],
+            left_index=True,
+            right_index=True,
+            indicator=True,
+            how="left",
+            sort=False,
+        )
         .reset_index(drop=True)
     ).sort_values(["B", "Numeric", "Floats"], ignore_index=True)
 
@@ -1539,6 +1710,7 @@ def test_dual_conditions_gt_and_lt_numbers_left_join(df, right):
             ("B", "Floats", "<"),
             how="left",
             sort_by_appearance=True,
+            indicator=True,
         )
         .sort_values(["B", "Numeric", "Floats"], ignore_index=True)
     )
@@ -1568,7 +1740,14 @@ def test_dual_conditions_gt_and_lt_numbers_right_join(df, right):
     expected.index.name = None
     expected = (
         expected[["B"]]
-        .join(right[["Numeric", "Floats"]], how="right", sort=False)
+        .merge(
+            right[["Numeric", "Floats"]],
+            left_index=True,
+            right_index=True,
+            indicator=True,
+            how="right",
+            sort=False,
+        )
         .sort_values(["Numeric", "Floats", "B"], ignore_index=True)
         .reset_index(drop=True)
     )
@@ -1580,6 +1759,7 @@ def test_dual_conditions_gt_and_lt_numbers_right_join(df, right):
             ("B", "Numeric", ">"),
             ("B", "Floats", "<"),
             how="right",
+            indicator=True,
         )
         .sort_values(["Numeric", "Floats", "B"], ignore_index=True)
     )
@@ -1700,7 +1880,7 @@ def test_dual_ne_dates(df, right):
     filters = ["A", "Integers", "E", "Dates"]
     expected = (
         df[["A", "E"]]
-        .merge(right[["Integers", "Dates"]], how="cross")
+        .merge(right[["Integers", "Dates"]], indicator=True, how="cross")
         .loc[lambda df: df.A.ne(df.Integers) & df.E.ne(df.Dates)]
         .sort_values(filters, ignore_index=True)
     )
@@ -1713,6 +1893,7 @@ def test_dual_ne_dates(df, right):
             ("E", "Dates", "!="),
             how="inner",
             sort_by_appearance=False,
+            indicator=True,
         )
         .sort_values(filters, ignore_index=True)
     )
@@ -2572,6 +2753,71 @@ def test_dual_ge_and_le_range_numbers_numba(df, right):
     assert_frame_equal(expected, actual)
 
 
+@pytest.mark.turtle
+@settings(deadline=None)
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ge_and_le_range_numbers_df_columns_only(df, right):
+    """Test output for multiple conditions and select df only."""
+
+    columns = ["A", "E"]
+    expected = (
+        df.merge(
+            right,
+            how="cross",
+        )
+        .loc[lambda df: df.A.ge(df.Integers) & df.E.lt(df.Dates), columns]
+        .sort_values(columns, ignore_index=True)
+    )
+
+    actual = (
+        df[["A", "E"]]
+        .conditional_join(
+            right[["Integers", "Dates"]],
+            ("E", "Dates", "<"),
+            ("A", "Integers", ">="),
+            how="inner",
+            use_numba=False,
+            right_columns=None,
+            sort_by_appearance=False,
+        )
+        .sort_values(columns, ignore_index=True)
+    )
+
+    assert_frame_equal(expected, actual)
+
+
+@pytest.mark.turtle
+@settings(deadline=None)
+@given(df=conditional_df(), right=conditional_right())
+def test_dual_ge_and_le_range_numbers_right_only(df, right):
+    """Test output for multiple conditions and select right only."""
+
+    columns = ["Integers", "Dates"]
+    expected = (
+        df.merge(
+            right,
+            how="cross",
+        )
+        .loc[lambda df: df.A.ge(df.Integers) & df.E.lt(df.Dates), columns]
+        .sort_values(columns, ignore_index=True)
+    )
+
+    actual = (
+        df[["A", "E"]]
+        .conditional_join(
+            right[["Integers", "Dates"]],
+            ("E", "Dates", "<"),
+            ("A", "Integers", ">="),
+            how="inner",
+            df_columns=None,
+            sort_by_appearance=False,
+        )
+        .sort_values(columns, ignore_index=True)
+    )
+
+    assert_frame_equal(expected, actual)
+
+
 @settings(deadline=None)
 @given(df=conditional_df(), right=conditional_right())
 @pytest.mark.turtle