Repository: pandas-dev/pandas

Issue:
ENH: option to check merge is one-to-one, many-to-one, one-to-many, or many-to-many
In my experience, there are few places in data work where problems with data are more evident than when merging datasets -- something that is both a problem (if you think you're doing a one-to-one merge and one of the keys isn't unique in one dataset, you can introduce huge problems) and an opportunity (checking that a merge works as expected is a great way to catch problems). 

With that in mind, I'd like to propose adding a `check_merge` argument that accepts strings `one_to_one`, `one_to_many`, `may_to_one`, and `many_to_many`. If one of those strings is passed, the `merge` function would then check uniqueness of merge variables before running the merge, and if for example the merge key for the first dataset has duplicates in a `one_to_many` merge, it would throw an (informative) exception. 

(Stata made a similar move to bake this functionality into its merge command around Stata 12 using `1:1`, `1:m`, `m:1` syntax, which I'm also open to). 

Though this functionality can be replicated with user tests, it gets tiring to write them every time...


Discussion:
see also #15068 
I like the stat syntax, so maybe for readability accept both. Also call this ``check=`` I think. or maybe ``validate=``
Sounds good. Will pull together a PR and ping for input. 

Resolving patch:
diff --git a/pandas/core/frame.py b/pandas/core/frame.py
--- a/pandas/core/frame.py
+++ b/pandas/core/frame.py
@@ -175,6 +175,19 @@
 
     .. versionadded:: 0.17.0
 
+validate : string, default None
+    If specified, checks if merge is of specified type.
+
+    * "one_to_one" or "1:1": check if merge keys are unique in both
+      left and right datasets.
+    * "one_to_many" or "1:m": check if merge keys are unique in left
+      dataset.
+    * "many_to_one" or "m:1": check if merge keys are unique in right
+      dataset.
+    * "many_to_may" or "m:m": allowed, but does not result in checks.
+
+    .. versionadded:: 0.21.0
+
 Examples
 --------
 
@@ -4868,12 +4881,13 @@ def _join_compat(self, other, on=None, how='left', lsuffix='', rsuffix='',
     @Appender(_merge_doc, indents=2)
     def merge(self, right, how='inner', on=None, left_on=None, right_on=None,
               left_index=False, right_index=False, sort=False,
-              suffixes=('_x', '_y'), copy=True, indicator=False):
+              suffixes=('_x', '_y'), copy=True, indicator=False,
+              validate=None):
         from pandas.core.reshape.merge import merge
         return merge(self, right, how=how, on=on, left_on=left_on,
                      right_on=right_on, left_index=left_index,
                      right_index=right_index, sort=sort, suffixes=suffixes,
-                     copy=copy, indicator=indicator)
+                     copy=copy, indicator=indicator, validate=validate)
 
     def round(self, decimals=0, *args, **kwargs):
         """
diff --git a/pandas/core/reshape/merge.py b/pandas/core/reshape/merge.py
--- a/pandas/core/reshape/merge.py
+++ b/pandas/core/reshape/merge.py
@@ -46,11 +46,13 @@
 @Appender(_merge_doc, indents=0)
 def merge(left, right, how='inner', on=None, left_on=None, right_on=None,
           left_index=False, right_index=False, sort=False,
-          suffixes=('_x', '_y'), copy=True, indicator=False):
+          suffixes=('_x', '_y'), copy=True, indicator=False,
+          validate=None):
     op = _MergeOperation(left, right, how=how, on=on, left_on=left_on,
                          right_on=right_on, left_index=left_index,
                          right_index=right_index, sort=sort, suffixes=suffixes,
-                         copy=copy, indicator=indicator)
+                         copy=copy, indicator=indicator,
+                         validate=validate)
     return op.get_result()
 
 
@@ -341,6 +343,7 @@ def merge_asof(left, right, on=None,
 
         .. versionadded:: 0.20.0
 
+
     Returns
     -------
     merged : DataFrame
@@ -504,7 +507,8 @@ class _MergeOperation(object):
     def __init__(self, left, right, how='inner', on=None,
                  left_on=None, right_on=None, axis=1,
                  left_index=False, right_index=False, sort=True,
-                 suffixes=('_x', '_y'), copy=True, indicator=False):
+                 suffixes=('_x', '_y'), copy=True, indicator=False,
+                 validate=None):
         self.left = self.orig_left = left
         self.right = self.orig_right = right
         self.how = how
@@ -567,6 +571,12 @@ def __init__(self, left, right, how='inner', on=None,
         # to avoid incompat dtypes
         self._maybe_coerce_merge_keys()
 
+        # If argument passed to validate,
+        # check if columns specified as unique
+        # are in fact unique.
+        if validate is not None:
+            self._validate(validate)
+
     def get_result(self):
         if self.indicator:
             self.left, self.right = self._indicator_pre_merge(
@@ -958,6 +968,49 @@ def _validate_specification(self):
         if len(self.right_on) != len(self.left_on):
             raise ValueError("len(right_on) must equal len(left_on)")
 
+    def _validate(self, validate):
+
+        # Check uniqueness of each
+        if self.left_index:
+            left_unique = self.orig_left.index.is_unique
+        else:
+            left_unique = MultiIndex.from_arrays(self.left_join_keys
+                                                 ).is_unique
+
+        if self.right_index:
+            right_unique = self.orig_right.index.is_unique
+        else:
+            right_unique = MultiIndex.from_arrays(self.right_join_keys
+                                                  ).is_unique
+
+        # Check data integrity
+        if validate in ["one_to_one", "1:1"]:
+            if not left_unique and not right_unique:
+                raise ValueError("Merge keys are not unique in either left"
+                                 " or right dataset; not a one-to-one merge")
+            elif not left_unique:
+                raise ValueError("Merge keys are not unique in left dataset;"
+                                 " not a one-to-one merge")
+            elif not right_unique:
+                raise ValueError("Merge keys are not unique in right dataset;"
+                                 " not a one-to-one merge")
+
+        elif validate in ["one_to_many", "1:m"]:
+            if not left_unique:
+                raise ValueError("Merge keys are not unique in left dataset;"
+                                 "not a one-to-many merge")
+
+        elif validate in ["many_to_one", "m:1"]:
+            if not right_unique:
+                raise ValueError("Merge keys are not unique in right dataset;"
+                                 " not a many-to-one merge")
+
+        elif validate in ['many_to_many', 'm:m']:
+            pass
+
+        else:
+            raise ValueError("Not a valid argument for validate")
+
 
 def _get_join_indexers(left_keys, right_keys, sort=False, how='inner',
                        **kwargs):


Test patch:
diff --git a/pandas/tests/reshape/test_merge.py b/pandas/tests/reshape/test_merge.py
--- a/pandas/tests/reshape/test_merge.py
+++ b/pandas/tests/reshape/test_merge.py
@@ -724,6 +724,130 @@ def test_indicator(self):
                           how='outer', indicator=True)
         assert_frame_equal(test5, hand_coded_result)
 
+    def test_validation(self):
+        left = DataFrame({'a': ['a', 'b', 'c', 'd'],
+                          'b': ['cat', 'dog', 'weasel', 'horse']},
+                         index=range(4))
+
+        right = DataFrame({'a': ['a', 'b', 'c', 'd', 'e'],
+                           'c': ['meow', 'bark', 'um... weasel noise?',
+                                 'nay', 'chirp']},
+                          index=range(5))
+
+        # Make sure no side effects.
+        left_copy = left.copy()
+        right_copy = right.copy()
+
+        result = merge(left, right, left_index=True, right_index=True,
+                       validate='1:1')
+        assert_frame_equal(left, left_copy)
+        assert_frame_equal(right, right_copy)
+
+        # make sure merge still correct
+        expected = DataFrame({'a_x': ['a', 'b', 'c', 'd'],
+                              'b': ['cat', 'dog', 'weasel', 'horse'],
+                              'a_y': ['a', 'b', 'c', 'd'],
+                              'c': ['meow', 'bark', 'um... weasel noise?',
+                                    'nay']},
+                             index=range(4),
+                             columns=['a_x', 'b', 'a_y', 'c'])
+
+        result = merge(left, right, left_index=True, right_index=True,
+                       validate='one_to_one')
+        assert_frame_equal(result, expected)
+
+        expected_2 = DataFrame({'a': ['a', 'b', 'c', 'd'],
+                                'b': ['cat', 'dog', 'weasel', 'horse'],
+                                'c': ['meow', 'bark', 'um... weasel noise?',
+                                      'nay']},
+                               index=range(4))
+
+        result = merge(left, right, on='a', validate='1:1')
+        assert_frame_equal(left, left_copy)
+        assert_frame_equal(right, right_copy)
+        assert_frame_equal(result, expected_2)
+
+        result = merge(left, right, on='a', validate='one_to_one')
+        assert_frame_equal(result, expected_2)
+
+        # One index, one column
+        expected_3 = DataFrame({'b': ['cat', 'dog', 'weasel', 'horse'],
+                                'a': ['a', 'b', 'c', 'd'],
+                                'c': ['meow', 'bark', 'um... weasel noise?',
+                                      'nay']},
+                               columns=['b', 'a', 'c'],
+                               index=range(4))
+
+        left_index_reset = left.set_index('a')
+        result = merge(left_index_reset, right, left_index=True,
+                       right_on='a', validate='one_to_one')
+        assert_frame_equal(result, expected_3)
+
+        # Dups on right
+        right_w_dups = right.append(pd.DataFrame({'a': ['e'], 'c': ['moo']},
+                                    index=[4]))
+        merge(left, right_w_dups, left_index=True, right_index=True,
+              validate='one_to_many')
+
+        with pytest.raises(ValueError):
+            merge(left, right_w_dups, left_index=True, right_index=True,
+                  validate='one_to_one')
+
+        with pytest.raises(ValueError):
+            merge(left, right_w_dups, on='a', validate='one_to_one')
+
+        # Dups on left
+        left_w_dups = left.append(pd.DataFrame({'a': ['a'], 'c': ['cow']},
+                                               index=[3]))
+        merge(left_w_dups, right, left_index=True, right_index=True,
+              validate='many_to_one')
+
+        with pytest.raises(ValueError):
+            merge(left_w_dups, right, left_index=True, right_index=True,
+                  validate='one_to_one')
+
+        with pytest.raises(ValueError):
+            merge(left_w_dups, right, on='a', validate='one_to_one')
+
+        # Dups on both
+        merge(left_w_dups, right_w_dups, on='a', validate='many_to_many')
+
+        with pytest.raises(ValueError):
+            merge(left_w_dups, right_w_dups, left_index=True,
+                  right_index=True, validate='many_to_one')
+
+        with pytest.raises(ValueError):
+            merge(left_w_dups, right_w_dups, on='a',
+                  validate='one_to_many')
+
+        # Check invalid arguments
+        with pytest.raises(ValueError):
+            merge(left, right, on='a', validate='jibberish')
+
+        # Two column merge, dups in both, but jointly no dups.
+        left = DataFrame({'a': ['a', 'a', 'b', 'b'],
+                          'b': [0, 1, 0, 1],
+                          'c': ['cat', 'dog', 'weasel', 'horse']},
+                         index=range(4))
+
+        right = DataFrame({'a': ['a', 'a', 'b'],
+                           'b': [0, 1, 0],
+                           'd': ['meow', 'bark', 'um... weasel noise?']},
+                          index=range(3))
+
+        expected_multi = DataFrame({'a': ['a', 'a', 'b'],
+                                    'b': [0, 1, 0],
+                                    'c': ['cat', 'dog', 'weasel'],
+                                    'd': ['meow', 'bark',
+                                          'um... weasel noise?']},
+                                   index=range(3))
+
+        with pytest.raises(ValueError):
+            merge(left, right, on='a', validate='1:1')
+
+        result = merge(left, right, on=['a', 'b'], validate='1:1')
+        assert_frame_equal(result, expected_multi)
+
 
 def _check_merge(x, y):
     for how in ['inner', 'left', 'outer']: