Repository: pyjanitor-devs/pyjanitor

Issue:
Case_when function
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

I would like to propose a `case_when` function, similar to the same function in SQL and R, for conditions. It will be a wrapper around `np.select`.

# Example API
```python
       Type	Set
1	A	Z
2	B	Z
3	B	X
4	C	Y
```

```python
# run checks based on conditions and create a new column, or modify existing column.
@pf.register_dataframe_method
def case_when(df, conditions,column_name, default = None, ) : 
    condlist, choicelist = zip(*[(value,key) for key, value in conditions.items()])
    df = df.assign(**{column_name : np.select(condlist, choicelist, default=default)})
    return df

cond = {"green" : df.Set=="Z", "yellow":df.Set=="X"}
df.case_when(conditions=cond, default=None, column_name='rag')

       Type	Set	rag
1	A	Z	green
2	B	Z	green
3	B	X	yellow
4	C	Y	None
```



Discussion:
Nice proposal, @samukweku! Can I check, how would this be different from the use of `transform_column`? Example below:

```python
mappings = {"Z": "green", "X": "yellow"}
df.transform_column("Set", lambda x: mappings.get(x, None), "rag")
```

I _think_ this will give the same result. I can see, however, how the `case_when` syntax can be friendlier to a beginner user. Because `conditions` is passed in as a dictionary, though, I think the `dict.get(x, default)` pattern might be easier for maintenance. What are your thoughts?
@ericmjl , case_when has a friendlier syntax. my actual goal with it is for multiple conditions, similar to `if-elif-else`. I am not sure if  `transform_column` can handle multiple `if-elif-else` scenarios, which `numpy select` adequately covers.
I think this is the same as update_where?  We were going to call it case_when but I was thinking to use the SQL name instead.  

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
Let's say we have some arbitrary dataframe, and our conditions fit into this : 

```python
np.where(consumption_energy > 400, 'high', 
         (np.where(consumption_energy < 200, 'low', 'medium')))
```
Is this replicable with ``update_where``? The above is hard to maintain and understand, whereas `numpy select` can help:

```python
cond1 = consumption_energy > 400
cond2 = consumption_energy < 200
condlist = [cond1, cond2]
choicelist = ['high', 'low']

np.select(condlist, choicelist, default='medium')
```
Any thoughts on this?
This is another example to highlight what `numpy select` can cover, which is similar to the `case when` function in sql : 

```python
df = pd.DataFrame({
    "a": [0,0,1,2],
    "b": [0,3,4,5],
    "c": [6,7,8,9]})
df

        a	b	c
0	0	0	6
1	0	3	7
2	1	4	8
3	2	5	9

cond1 = df.a > 0
cond2 = df.b > 0
condlist = [cond1, cond2]
choicelist = [df.a, df.b]
df['value'] = np.select(condlist, choicelist, df.c)

	a	b	c	value
0	0	0	6	6
1	0	3	7	3
2	1	4	8	1
3	2	5	9	2
```

I do not think `update_where` can pull this off, as it will replace already existing values in the `value` column, whereas `numpy select` fulfils the conditions in a hierarchical form. Your thoughts on this  @ericmjl @jcvall ?
> @ericmjl , case_when has a friendlier syntax. my actual goal with it is for multiple conditions, similar to `if-elif-else`. I am not sure if `transform_column` can handle multiple `if-elif-else` scenarios, which `numpy select` adequately covers.

I see. `transform_column` definitely can handle the `if-elif-else` scenarios by way of a `dict.get(x, default_value)` mapping. However, `dict.get(x, default_value)` will fail when we want to do the operation column-wise. And `dict.get(x, default_value)` doesn't _fully_ replicate what `np.select` does, I think. 

Is it correct to say that the `case_when` semantics are as follows?

- It always creates a new column, rather than mutating an existing column like `update_where`.
- It operates on entire dataframe columns, in contrast to element-wise in a `transform_column` call.
- It operates on arbitrary conditions, which are evaluated in the provided order.
    - If a condition returns true, then that condition's corresponding choice is returned.
    - If no condition returns true, then a default value is taken.

@samukweku are there semantics that are incorrect or missing above?

There's a semantic pattern showing up for me. There's operations that we do element-wise, row-wise, column-wise, and dataframe-wide, and they have somewhat different semantics. I'm liking these proposals, they're prompting thoughts for me, particularly w.r.t. a "grammar" for manipulating a dataframe. It's like we're engaged in linguistic research here!
In the first example you would break it up into two chained statements:

(df.add_column('consumption_energy_usage', 'medium').
      .update_where(df.consumption_energy > 400 , 'consumption_energy_usage' , 'high')
     .update_where(df.consumption_energy < 200, 'consumption_energy_usage', 'low'))

You can create a copy of column a and do the same thing in your second example.

![image](https://user-images.githubusercontent.com/20308031/92394330-5c067b00-f0ef-11ea-8384-a4b61ca3484e.png)


You can also not make a copy of column a and just let the values go NA.
df.update_where((df['a'] == 0) & (df['b'] == 0), 'value', df['c'])

![image](https://user-images.githubusercontent.com/20308031/92394436-8a845600-f0ef-11ea-9059-682c9fb43c0b.png)


@ericmjl `case when` can mutate an existing column, or create a new column, just like `update_where`.
Yes, it operates on entire dataframe columns.
The other semantics are true. One thing to note about `numpy select` or sql's `case when` is that if a value exists for a condition, a new condition cannot override that. In the example I shared,` b>0 `cannot replace value gotten for `a > 0`; well, should not.
You can update an existing column, yes.
@jcvall notice how the second example in your code is not covered by `update_where`, instead returning nulls.
That was a third example where if you want to return nulls.  You can do the same thing in my first example that you did in the second.
I would play around with update_where and see what can't be done.  
@jcvall, let me know where I botched this : 

```python
(df.add_column('value', df.c)
 .update_where(df.a > 0, 'value', df.a)
 .update_where(df.b > 0, 'value', df.b))

  a  b  c  value
0  0  6  6
0  3  7  3
1  4  8  4
2  5  9  5
```
the output is different from the expected result.
I think the order you put it in.  Try this:

(df.add_column('value', df.c)
 .update_where(df.b > 0, 'value', df.b)
 .update_where(df.a > 0, 'value', df.a))

Apologies, I couldnt fight off sleep. @jcvall , this works fine. I think `update_where` covers this, along with `add_column`. The order made the difference. I will wait a while and close this. 
@samukweku but you _should_ sleep! :smile_cat: 😉.

Jokes aside, it looks like some routine chains probably could be made into "higher order" functions that call on the primitives, maybe? I do have this pattern in place in my private codebases at work, where functions that perform the same operation on dataframes that share the same schema (but do not necessarily have the same underlying data). The pattern here appears to be:

- transform/add column using "else" condition
- then walk backwards to through the "elif" conditions until the "if" condition is executed.
@jcvall, @ericmjl , bear with me if I am flogging this issue too hard, just need to check as many scenarios as possible. This example here is from stack overflow : https://stackoverflow.com/questions/48569166/multiple-if-else-conditions-in-pandas-dataframe-and-derive-multiple-columns/48569899
```python
raw_data = {'student':['A','B','C','D','E'],
        'score': [100, 96, 80, 105,156], 
    'height': [7, 4,9,5,3],
    'trigger1' : [84,95,15,78,16],
    'trigger2' : [99,110,30,93,31],
    'trigger3' : [114,125,45,108,46]}

df2 = pd.DataFrame(raw_data, columns = ['student','score', 'height','trigger1','trigger2','trigger3'])

	student	score	height	trigger1	trigger2	trigger3
0	A	100	7	84	99	114
1	B	96	4	95	110	125
2	C	80	9	15	30	45
3	D	105	5	78	93	108
4	E	156	3	16	31	46

Conditions: Create a Flag column where:

if Score greater than equal trigger 1 and height less than 8 then Red --

if Score greater than equal trigger 2 and height less than 8 then Yellow --

if Score greater than equal trigger 3 and height less than 8 then Orange --

if height greater than 8 then leave it as blank


The solution provided used the `apply` function, which I would avoid and use `np.select` : 

```python
def flag_df(df):

    if (df['trigger1'] <= df['score'] < df['trigger2']) and (df['height'] < 8):
        return 'Red'
    elif (df['trigger2'] <= df['score'] < df['trigger3']) and (df['height'] < 8):
        return 'Yellow'
    elif (df['trigger3'] <= df['score']) and (df['height'] < 8):
        return 'Orange'
    elif (df['height'] > 8):
        return np.nan

df2['Flag'] = df2.apply(flag_df, axis = 1)
```

`update_where` does not cover this, or I may be using some wrong ordering/approach. Would love your take on this @jcvall 

@ericmjl what do you have in mind for the higher order functions? yes, there are scenarios that have this `if-elif-else` combos that we could cover. 

This conversation just put me on a `SO` hunt to see how robust/flexible `update_where` is and maybe we can tweak it, if necessary.
@jcvall `update_where` covers this pretty well; I just had to make some changes to my initial code, so a few more hours and I will close this issue. Thanks everyone. 
@samukweku I love the initiative here and the exploration! Will you write a blog post about this? I think it's totally worth a blog post. 😄If you write one, tweet it out, and I'll be sure to share it!
hehehe. good idea @ericmjl . I'll work on that.
@ericmjl @jcvall I had to reopen this issue. You'll have to blame @ericmjl who suggested I write a blog post... led me down the rabbit hole of performance. 
```python

In [1]: import pandas as pd
   ...:    ...: import numpy as np
   ...:    ...: import janitor
   ...: 

In [2]: df = pd.DataFrame({
   ...:     "a": [0,0,1,2],
   ...:     "b": [0,3,4,5],
   ...:     "c": [6,7,8,9]})

In [3]: df
Out[3]: 
   a  b  c
0  0  0  6
1  0  3  7
2  1  4  8
3  2  5  9

In [4]: %%timeit
   ...: cond1 = df.a > 0
   ...: cond2 = df.b > 0
   ...: condlist = [cond1, cond2]
   ...: choicelist = [df.a, df.b]
   ...: df['value'] = np.select(condlist, choicelist, df.c)
   ...: 
   ...: 
528 µs ± 1.07 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)



In [6]: df = pd.DataFrame({
   ...:     "a": [0,0,1,2],
   ...:     "b": [0,3,4,5],
   ...:     "c": [6,7,8,9]})

In [7]: %%timeit
   ...: (df.add_column('value', df.c)
   ...: .update_where(df.b > 0, 'value', df.b)
   ...: .update_where(df.a > 0, 'value', df.a))
   ...: 
   ...: 
2.22 ms ± 35.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

There is a significant speed difference. I havent done a line profile, however, I suspect calling the functions multiple times as well as the built in copy associated with each method are the reasons for the speed difference.
Hi samukweku,

Thanks for the example.  I would ask that this get tested in all different scenarios.  I would think that your solution is faster but does it work the same in all scenarios that update_where does now?  I would just have to say lets be very cautious here as I do rely on this function for many of my current reports at work.  

For example:  I am uncertain how your way deals with complex and/or statements to update a column.  I did an example of one and I didn't get the same result.  The update_where function is correct, but I don't know how to create it in your example.

![Screen Shot 2020-09-13 at 12 44 16 AM](https://user-images.githubusercontent.com/20308031/93010422-49aa9800-f55a-11ea-85af-4d3c15237cb5.png)

@jcvall, i am merely exploring the performance part of it. I'll give your example a go. do you mind sharing the example source data?

I had a go at it, and tweaked the conditions :
```python
cond1 = ((df.a==0) &(df.b!=0)) | (df.c=="wait") # this is where the tweak occurs; if a==0, then b should not be 0
cond2 = (df.b==0) & (df.a==0)
condlist = [cond1, cond2]
choicelist = [df.a, "x"]
df['value'] = np.select(condlist, choicelist, df.c)

        a	b	c	value
0	0	0	6	x
1	0	3	7	0
2	1	4	8	8
3	2	5	9	9
4	hi	bye	wait	hi
```

I feel update_where excels at a single condition, whereas the `case_when` would cover multiple conditions. Alternatively, if we made `update_where` deal with multiple conditions, then the current mode should not be changed, at least to ensure backward compatibility. 
samukweku...thanks for this. 

 Maybe my choice of not naming what is update_where() to case_when() was just meant to be.

High level, the update_where acts like a chained line of code in the tidyverse that has %>% operator between  each step.  Your case_when acts a lot like the case_when function inside of the mutate function.  So, your example is very close to the true case_when in R.

I think there is room enough for both functions here.  If we agree not to change update_where and create a separate function case_when() that can work for me.  I also wanted to say that I have notice the great work you put in to this library. I do want to thank you for your contributions.

As for your example you just gave,  I think you glossed over a possible bug in the case_when by changing the condition above to get the desired output.

I think you need to address that cond2 doesn't go back to row 1 and update to an 'x' like expected in my example.  Something to think about.  

I would also like to see how the actual case_when function would look when you set it up with the flavor parameter.  

I don't think I will be able to reply back as fast  as things will get crazy for me this week.

Great work all around.  And as always thanks to ericmjl for starting such a great library.
@jcvall thanks for the feedback. I agree that `update_where` should not be changed. I'll just leave this open for a while and if it does not gain traction, I'll close it. Thanks everyone for your feedback.
I think I'm pretty late to this, but I was also trying to come up with a flexible `case_when` clone that would work similarly to the way @samukweku was describing (inside of a `mutate()` statement).

This is sort of what I was working with (still not sure how to not have this work _in place_, which worries me, but I like the syntax better):

```python
@pf.register_dataframe_method
def case_when(df, column_name: str, *args):
  mapper = {"cond" : [], "~" : []}
  for query, result in args:
    mapper['cond'].append(query)
    mapper['~'].append(result)
  df[column_name] = np.select(
    condlist = mapper.get("cond")
    choicelist = mapper.get("~"))
  return(df)
```

```python
(df
  .case_when(
    "value",
    [((df.a == 0) & (df.b != 0)) | (df.c == "wait"), df.a],
    [(df.b == 0) & (df.a == 0), "x"],
    [True, df.c]))
```

Ends up look this:

![Screen Shot 2020-11-05 at 11 00 09 PM](https://user-images.githubusercontent.com/4935203/98335865-a9ea1300-1fba-11eb-951c-ce2f1f5d5f26.png)

Thoughts?
@robertmitchellv you are on track with the idea. I was hoping somehow to pull it off without repeating `df` in the conditions. The speed benefits of numpy select plus being able to wrap the conditions in one go are some of the appeal of `case_when`. Let's see the responses from others. 
@jcvall @ericmjl I could be wrong but I think when an issue is closed there's no ping when new comments are made--if not, hopefully this ping isn't a bother! 🙇‍♂️
@robertmitchellv not at all! @samukweku if you'd like to, please go ahead and re-open the issue. You've given a lot of thought to this part of the API, so I'm happy to defer to you on this one.
@robertmitchellv I will open it and then you can submit a PR
Ok, happy to submit a PR, but is there anyway you can think of to not make this an in-place function? I think I'm having a hard time transitioning from R's NSE 🤣
@robertmitchellv it's possible to do a `df.copy()` operation, maybe that will help avoid the in-place mutation?
@ericmjl I can try that--I just want to avoid the `numpy.select()` changing the data prior to assignment. Is there a `pyjanitor` design principle here or function I can look at that would give me a good idea of how to do this? Appreciate it!

@robertmitchellv maybe [select_columns](https://pyjanitor.readthedocs.io/reference/janitor.functions/janitor.select_columns.html#janitor.select_columns) or [coalesce](https://pyjanitor.readthedocs.io/reference/janitor.functions/janitor.coalesce.html#janitor.coalesce) ?
@samukweku @ericmjl nevermind, I figured it out! `df.copy()` was the solution I wanted. I'll add the function metadata and then submit a PR.

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -60,6 +60,7 @@
     _conditional_join_preliminary_checks,
     _conditional_join_compute,
     _cond_join_suffixes,
+    _case_when,
 )
 
 
@@ -6859,3 +6860,111 @@ def conditional_join(
     return _conditional_join_compute(
         df, right, conditions, how, sort_by_appearance
     )
+
+
+@pf.register_dataframe_method
+def case_when(df: pd.DataFrame, *args, column_name: str) -> pd.DataFrame:
+    """
+    Convenience function for creating a column,
+    based on a condition, or multiple conditions.
+
+    It similar to SQL and dplyr's case_when,
+    with inspiration from `pydatatable` if_else function.
+
+    If your scenario requires direct replacement of values,
+    pandas' `replace` method or `map` method should be better
+    suited and more efficient; if the conditions scenario checks
+    if a value is within a range of values, pandas' `cut` or `qcut`
+    should be more efficient; `np.where/np.select` are also
+    performant options.
+
+    This function relies on `pd.Series.mask` method.
+
+    When multiple conditions are satisfied, the first one is used.
+
+    The variable `*args` parameters takes arguments of the form :
+    `condition0`, `value0`, `condition1`, `value1`, ..., `default`.
+    If `condition0` evaluates to `True`, then assign `value0` to
+    `column_name`, if `condition1` evaluates to `True`, then
+    assign `value1` to `column_name`, and so on. If none of the
+    conditions evaluate to `True`, assign `default` to
+    `column_name`.
+
+    This function can be likened to SQL's `case_when`:
+
+    .. code-block:: SQL
+
+        CASE WHEN condition0 THEN value0
+             WHEN condition1 THEN value1
+            --- more conditions
+             ELSE default
+        END AS column_name
+
+    compared to python's `if-elif-else`:
+
+    .. code-block:: python
+
+        if condition0:
+            value0
+        elif condition1:
+            value1
+        # more elifs
+        else:
+            default
+
+
+
+    Functional usage syntax:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor as jn
+
+        df = pd.DataFrame(...)
+        right = pd.DataFrame(...)
+
+        df = jn.case_when(
+                df,
+                condition0, result0,
+                condition1, result1,
+                ...,
+                default,
+                column_name = 'column',
+                )
+
+    Method chaining syntax:
+
+    .. code-block:: python
+
+        df = df.case_when(
+                condition0, result0,
+                condition1, result1,
+                ...,
+                default,
+                column_name = 'column',
+                )
+
+    :param df: A Pandas dataframe.
+    :param args: Variable argument of conditions and expected values.
+        Takes the form
+        `condition0`, `value0`, `condition1`, `value1`, ..., `default`.
+        `condition` can be a 1-D boolean array, a callable, or a string.
+        If `condition` is a callable, it should evaluate
+        to a 1-D boolean array. The array should have the same length
+        as the DataFrame. If it is a string, it is computed on the dataframe,
+        via `df.eval`, and should return a 1-D boolean array.
+        `result` can be a scalar, a 1-D array, or a callable.
+        If `result` is a callable, it should evaluate to a 1-D array.
+        For a 1-D array, it should have the same length as the DataFrame.
+        The `default` argument applies if none of `condition0`,
+        `condition1`, ..., evaluates to `True`.
+        Value can be a scalar, a callabe, or a 1-D array. if `default` is a
+        callable, it should evaluate to a 1-D array.
+        The 1-D array should be the same length as the DataFrame.
+    :param column_name: Name of column to assign results to. A new column
+        is created, if it does not already exist in the DataFrame.
+    :returns: A pandas DataFrame.
+    """
+
+    return _case_when(df, args, column_name)
diff --git a/janitor/utils.py b/janitor/utils.py
--- a/janitor/utils.py
+++ b/janitor/utils.py
@@ -10,7 +10,7 @@
 import warnings
 import operator
 from collections.abc import Callable as dispatch_callable
-from itertools import chain
+from itertools import chain, count
 from typing import (
     Callable,
     Dict,
@@ -3470,3 +3470,110 @@ def _conditional_join_compute(
     return _create_conditional_join_frame(
         df, right, left_c, right_c, how, sort_by_appearance
     )
+
+
+def _case_when_checks(df: pd.DataFrame, args, column_name):
+    """
+    Preliminary checks on the case_when function.
+    """
+    if len(args) < 3:
+        raise ValueError(
+            """
+            At least three arguments are required
+            for the `args` parameter.
+            """
+        )
+    if len(args) % 2 != 1:
+        raise ValueError(
+            """
+            It seems the `default` argument is missing
+            from the variable `args` parameter.
+            """
+        )
+
+    check("column_name", column_name, [str])
+
+    *args, default = args
+
+    booleans = []
+    replacements = []
+    for index, value in enumerate(args):
+        if index % 2 == 0:
+            booleans.append(value)
+        else:
+            replacements.append(value)
+
+    conditions = []
+    for condition in booleans:
+        if callable(condition):
+            condition = apply_if_callable(condition, df)
+        elif isinstance(condition, str):
+            condition = df.eval(condition)
+        conditions.append(condition)
+
+    targets = []
+    for replacement in replacements:
+        if callable(replacement):
+            replacement = apply_if_callable(replacement, df)
+        targets.append(replacement)
+
+    if callable(default):
+        default = apply_if_callable(default, df)
+    if not is_list_like(default):
+        default = pd.Series([default]).repeat(len(df))
+        default.index = df.index
+    if not hasattr(default, "shape"):
+        default = pd.Series([*default])
+    if isinstance(default, pd.Index):
+        arr_ndim = default.nlevels
+    else:
+        arr_ndim = default.ndim
+    if arr_ndim != 1:
+        raise ValueError(
+            """
+            The `default` argument should either be a 1-D array,
+            a scalar, or a callable that can evaluate to
+            a 1-D array.
+            """
+        )
+    if not isinstance(default, pd.Series):
+        default = pd.Series(default)
+    if default.size != len(df):
+        raise ValueError(
+            """
+            The length of the `default` argument
+            should be equal to the length
+            of the DataFrame.
+            """
+        )
+    return conditions, targets, default
+
+
+def _case_when(df: pd.DataFrame, args, column_name):
+    """
+    Actual computation of the case_when function.
+    """
+    conditions, targets, default = _case_when_checks(df, args, column_name)
+
+    if len(conditions) == 1:
+        default = default.mask(conditions[0], targets[0])
+        return df.assign(**{column_name: default})
+
+    # ensures value assignment is on a first come basis
+    conditions = conditions[::-1]
+    targets = targets[::-1]
+    for condition, value, index in zip(conditions, targets, count()):
+        try:
+            default = default.mask(condition, value)
+        # error `feedoff` idea from SO
+        # https://stackoverflow.com/a/46091127/7175713
+        except Exception as e:
+            raise ValueError(
+                f"""
+                condition{index} and value{index}
+                failed to evaluate.
+                Original error message: {e}
+                """
+            ) from e
+
+    return df.assign(**{column_name: default})


Test patch:
diff --git a/tests/functions/test_case_when.py b/tests/functions/test_case_when.py
new file mode 100644
--- /dev/null
+++ b/tests/functions/test_case_when.py
@@ -0,0 +1,173 @@
+import pandas as pd
+import pytest
+import numpy as np
+from pandas.testing import assert_frame_equal
+from hypothesis import assume, given
+from janitor.testing_utils.strategies import (
+    df_strategy,
+    categoricaldf_strategy,
+)
+
+
+def test_case_when_1():
+    """Test case_when function."""
+    df = pd.DataFrame(
+        {
+            "a": [0, 0, 1, 2, "hi"],
+            "b": [0, 3, 4, 5, "bye"],
+            "c": [6, 7, 8, 9, "wait"],
+        }
+    )
+    expected = pd.DataFrame(
+        {
+            "a": [0, 0, 1, 2, "hi"],
+            "b": [0, 3, 4, 5, "bye"],
+            "c": [6, 7, 8, 9, "wait"],
+            "value": ["x", 0, 8, 9, "hi"],
+        }
+    )
+    result = df.case_when(
+        ((df.a == 0) & (df.b != 0)) | (df.c == "wait"),
+        df.a,
+        (df.b == 0) & (df.a == 0),
+        "x",
+        df.c,
+        column_name="value",
+    )
+
+    assert_frame_equal(result, expected)
+
+
+@given(df=df_strategy())
+def test_len_args(df):
+    """Raise ValueError if `args` length is less than 3."""
+    with pytest.raises(ValueError):
+        df.case_when(df.a < 10, "less_than_10", column_name="a")
+
+
+@given(df=df_strategy())
+def test_args_even(df):
+    """Raise ValueError if `args` length is even."""
+    with pytest.raises(ValueError):
+        df.case_when(
+            df.a < 10, "less_than_10", df.a == 5, "five", column_name="a"
+        )
+
+
+@given(df=df_strategy())
+def test_column_name(df):
+    """Raise TypeError if `column_name` is not a string."""
+    with pytest.raises(TypeError):
+        df.case_when(df.a < 10, "less_than_10", df.a, column_name=("a",))
+
+
+@given(df=df_strategy())
+def test_default_ndim(df):
+    """Raise ValueError if `default` ndim > 1."""
+    with pytest.raises(ValueError):
+        df.case_when(df.a < 10, "less_than_10", df, column_name="a")
+
+
+@given(df=df_strategy())
+def test_default_length(df):
+    """Raise ValueError if `default` length != len(df)."""
+    assume(len(df) > 10)
+    with pytest.raises(ValueError):
+        df.case_when(
+            df.a < 10, "less_than_10", df.loc[:5, "a"], column_name="a"
+        )
+
+
+@given(df=df_strategy())
+def test_error_multiple_conditions(df):
+    """Raise ValueError for multiple conditions."""
+    with pytest.raises(ValueError):
+        df.case_when(df.a < 10, "baby", df.a + 5, "kid", df.a, column_name="a")
+
+
+@given(df=df_strategy())
+def test_case_when_condition_callable(df):
+    """Test case_when for callable."""
+    result = df.case_when(
+        lambda df: df.a < 10, "baby", "bleh", column_name="bleh"
+    )
+    expected = np.where(df.a < 10, "baby", "bleh")
+    expected = df.assign(bleh=expected)
+    assert_frame_equal(result, expected)
+
+
+@given(df=df_strategy())
+def test_case_when_condition_eval(df):
+    """Test case_when for callable."""
+    result = df.case_when("a < 10", "baby", "bleh", column_name="bleh")
+    expected = np.where(df.a < 10, "baby", "bleh")
+    expected = df.assign(bleh=expected)
+    assert_frame_equal(result, expected)
+
+
+@given(df=df_strategy())
+def test_case_when_replacement_callable(df):
+    """Test case_when for callable."""
+    result = df.case_when(
+        "a > 10", lambda df: df.a + 10, lambda df: df.a * 2, column_name="bleh"
+    )
+    expected = np.where(df.a > 10, df.a + 10, df.a * 2)
+    expected = df.assign(bleh=expected)
+    assert_frame_equal(result, expected)
+
+
+@given(df=categoricaldf_strategy())
+def test_case_when_default_list(df):
+    """
+    Test case_when for scenarios where `default` is list-like,
+    but not a Pandas or numpy object.
+    """
+    default = range(len(df))
+    result = df.case_when(
+        "numbers > 1", lambda df: df.numbers + 10, default, column_name="bleh"
+    )
+    expected = np.where(df.numbers > 1, df.numbers + 10, default)
+    expected = df.assign(bleh=expected)
+    assert_frame_equal(result, expected)
+
+
+@given(df=categoricaldf_strategy())
+def test_case_when_default_index(df):
+    """Test case_when for scenarios where `default` is an index."""
+    default = range(len(df))
+    result = df.case_when(
+        "numbers > 1",
+        lambda df: df.numbers + 10,
+        pd.Index(default),
+        column_name="bleh",
+    )
+    expected = np.where(df.numbers > 1, df.numbers + 10, default)
+    expected = df.assign(bleh=expected)
+    assert_frame_equal(result, expected)
+
+
+@given(df=df_strategy())
+def test_case_when_multiple_args(df):
+    """Test case_when for multiple arguments."""
+    result = df.case_when(
+        df.a < 10,
+        "baby",
+        df.a.between(10, 20, "left"),
+        "kid",
+        lambda df: df.a.between(20, 30, "left"),
+        "young",
+        "30 <= a < 50",
+        "mature",
+        "grandpa",
+        column_name="elderly",
+    )
+    conditions = [
+        df["a"] < 10,
+        (df["a"] >= 10) & (df["a"] < 20),
+        (df["a"] >= 20) & (df["a"] < 30),
+        (df["a"] >= 30) & (df["a"] < 50),
+    ]
+    choices = ["baby", "kid", "young", "mature"]
+    expected = np.select(conditions, choices, "grandpa")
+    expected = df.assign(elderly=expected)
+    assert_frame_equal(result, expected)