Your AI workflow produces two project briefs. A later data join produces three rows. The model did not necessarily duplicate anything: a repeated key in the account lookup can multiply a perfectly ordinary input record.
Build an explicit join contract before those records feed another stage. This guide uses pandas to reject duplicate lookup keys, identify unmatched briefs and keep one output row per input brief. The example runs locally in memory; it does not call a model, send an email or update a CRM.
Two briefs, three rows
Orbit Desk is a fictional business preparing brief-review packets. Its authored sample has brief b1 for client c1 and brief b2 for c2. The clean lookup contains one account row for each client. Add a second c1 lookup row and an unchecked left join returns three rows: b1 matches twice, while b2 matches once. The sample names Lumen Bakes and Cedar Repairs are fictional, not clients or evidence of business results.
| Executed fixture | Result | Review implication |
|---|---|---|
| 2 briefs + duplicate c1 lookup, unchecked | 3 output rows | One brief multiplied |
| Same duplicate lookup, validated many-to-one | MergeError | Stop and inspect lookup |
| 2 briefs + authored clean lookup | 2 output rows | Cardinality contract holds |
| Brief refers to unknown client | ValueError names b1 | Resolve missing match |
Declare which side must be unique
The pandas merge reference defines many_to_one as a uniqueness check on the right-hand merge keys. That permits several distinct briefs for one client while requiring a single lookup row per client. It also documents the merge indicator and warns that null keys can match each other, unlike usual SQL behavior. The wrapper therefore rejects blank or null required values before merging.
Current documentation is for pandas 3.1; the executed package here was 3.0.2. The versioned 3.0.2 implementation checks right-key uniqueness and raises MergeError for an invalid many-to-one merge. The runtime fixture confirms that behavior on the installed version.
Cardinality and completeness are different checks. A unique lookup can still lack c1. A left join with an indicator preserves that brief long enough to report it as unmatched; the wrapper then stops instead of returning a partial review packet. The final row-count assertion is an additional invariant, not a substitute for unique keys and matched records.
Run the complete example
Prerequisites: the tests ran on Windows, Python 3.14.3, pandas 3.0.2 and NumPy 2.4.2. Use an environment where pandas is already available or prepare your own approved project environment. No packages were installed or upgraded for this article. The pandas repository uses BSD 3-Clause licensing; this guide does not bundle its binary dependencies.
Save the following as join_review.py in a new disposable directory. It builds authored DataFrames, runs the join function and prints a small summary. It does not load business files or make external requests. The schema is deliberately narrow: exact columns, nonblank strings, unique brief IDs and client IDs without surrounding whitespace.
"""In-memory authored business-brief join; no model or external actions."""
import json
import unittest
import pandas as pd
from pandas.errors import MergeError
def fixtures():
briefs = pd.DataFrame([
{'brief_id': 'b1', 'client_id': 'c1', 'summary': 'Review booking form'},
{'brief_id': 'b2', 'client_id': 'c2', 'summary': 'Review service page'},
])
clients = pd.DataFrame([
{'client_id': 'c1', 'account_name': 'Lumen Bakes'},
{'client_id': 'c2', 'account_name': 'Cedar Repairs'},
])
return briefs, clients
def require(frame, columns):
if not frame.columns.is_unique or set(frame.columns) != set(columns):
raise ValueError('Unexpected or duplicate column labels')
if frame.empty:
raise ValueError('This review requires nonempty tables')
for column in columns:
valid = frame[column].map(lambda v: isinstance(v, str) and bool(v.strip()))
if not valid.all():
raise ValueError('Every required value must be a nonblank string')
for column in set(columns) & {'brief_id', 'client_id'}:
if not frame[column].map(lambda v: v == v.strip()).all():
raise ValueError('IDs must not have surrounding whitespace')
def attach_accounts(briefs, clients):
require(briefs, ('brief_id', 'client_id', 'summary'))
require(clients, ('client_id', 'account_name'))
if not briefs['brief_id'].is_unique:
raise ValueError('Each brief_id must be unique')
joined = briefs.merge(clients, on='client_id', how='left',
validate='many_to_one', indicator=True, sort=False)
missing = joined.loc[joined['_merge'] != 'both', 'brief_id'].tolist()
if missing:
raise ValueError('Unmatched brief IDs: ' + ', '.join(missing))
if len(joined) != len(briefs):
raise ValueError('Unexpected row-count change')
return joined.drop(columns='_merge')
def demo():
briefs, clients = fixtures()
duplicate = pd.concat([clients, clients.iloc[[0]]], ignore_index=True)
expanded = briefs.merge(duplicate, on='client_id', how='left')
try:
attach_accounts(briefs, duplicate)
except MergeError:
blocked = True
else:
raise AssertionError('Duplicate lookup was not rejected')
approved = attach_accounts(briefs, clients)
return {'input_briefs': len(briefs), 'unchecked_rows': len(expanded),
'duplicate_lookup_rejected': blocked, 'reviewed_rows': len(approved),
'accounts': approved['account_name'].tolist()}
class JoinTests(unittest.TestCase):
def test_valid_join(self):
b, c = fixtures()
out = attach_accounts(b, c)
self.assertEqual(out['brief_id'].tolist(), ['b1', 'b2'])
self.assertEqual(out['account_name'].tolist(), ['Lumen Bakes', 'Cedar Repairs'])
def test_duplicate_lookup(self):
self.assertEqual(demo()['unchecked_rows'], 3)
self.assertTrue(demo()['duplicate_lookup_rejected'])
def test_missing_client(self):
b, c = fixtures()
b.loc[0, 'client_id'] = 'unknown'
with self.assertRaisesRegex(ValueError, 'Unmatched brief IDs: b1'):
attach_accounts(b, c)
def test_null_keys(self):
for side in ('briefs', 'clients'):
b, c = fixtures()
(b if side == 'briefs' else c).loc[0, 'client_id'] = None
with self.subTest(side=side), self.assertRaises(ValueError):
attach_accounts(b, c)
def test_whitespace_key(self):
b, c = fixtures()
b.loc[0, 'client_id'] = ' c1'
with self.assertRaisesRegex(ValueError, 'whitespace'):
attach_accounts(b, c)
def test_numeric_key(self):
b, c = fixtures()
b['client_id'] = [1, 2]
with self.assertRaises(ValueError):
attach_accounts(b, c)
def test_duplicate_brief(self):
b, c = fixtures()
with self.assertRaisesRegex(ValueError, 'brief_id'):
attach_accounts(pd.concat([b, b.iloc[[0]]]), c)
def test_multiple_briefs_one_client(self):
b, c = fixtures()
b.loc[1, 'client_id'] = 'c1'
self.assertEqual(attach_accounts(b, c)['account_name'].tolist(),
['Lumen Bakes', 'Lumen Bakes'])
def test_column_contract(self):
b, c = fixtures()
for bad in (b.rename(columns={'summary': 'text'}), b.assign(extra='x'),
pd.concat([b, b[['summary']]], axis=1)):
with self.subTest(columns=list(bad.columns)), self.assertRaises(ValueError):
attach_accounts(bad, c)
def test_blank_text(self):
b, c = fixtures()
b.loc[0, 'summary'] = ' '
with self.assertRaises(ValueError):
attach_accounts(b, c)
def test_source_frames_unchanged(self):
b, c = fixtures()
original_b, original_c = b.copy(deep=True), c.copy(deep=True)
attach_accounts(b, c)
pd.testing.assert_frame_equal(b, original_b)
pd.testing.assert_frame_equal(c, original_c)
def test_empty_table(self):
b, c = fixtures()
with self.assertRaisesRegex(ValueError, 'nonempty'):
attach_accounts(b.iloc[:0], c)
if __name__ == '__main__':
print(json.dumps(demo(), indent=2))
Run both commands from that directory. The first must report 12 passing test methods. The second reproduces the row multiplication and the guarded result.
python -X utf8 -m unittest -v join_review.py
python -X utf8 join_review.py
{
"input_briefs": 2,
"unchecked_rows": 3,
"duplicate_lookup_rejected": true,
"reviewed_rows": 2,
"accounts": [
"Lumen Bakes",
"Cedar Repairs"
]
}
Resolve the duplicate instead of hiding it
The demo does not repair its bad lookup by dropping duplicates. Its successful path uses a separate, authored clean fixture. In a real workflow, inspect why multiple lookup rows exist: accidental duplicate export, several account versions, or a relationship that was never many-to-one. Choose a documented current record or a richer key with the data owner. Arbitrarily keeping the first row can produce exactly one wrong match.
For tenant-scoped accounts, a client ID alone may be insufficient; a composite tenant/client key may be necessary. For time-dependent records, define the effective date. These policies are not implemented in the small example. Extend both the schema and tests before adapting it. Numeric IDs and whitespace are rejected rather than silently converted, because that conversion can erase meaningful distinctions such as leading zeros.
Call attach_accounts with already reviewed DataFrames and retain the raw inputs separately. Hold the batch on MergeError or ValueError and record the reason for review. Do not catch an error and continue with an unchecked merge. Multiple different brief IDs for c1 are intentionally allowed, so this is not a general duplicate-content detector. If similarity between company names is the problem, use a separate duplicate-company review queue.
A successful return is enriched data for the next review stage. It is not authorization to send or publish. Keep external delivery separate, as in the draft-only agent handoff.
What was confirmed
Twelve runtime test methods cover valid matching, actual row multiplication, duplicate lookup rejection, missing clients, null/whitespace/numeric IDs, repeated brief IDs, legitimate many-to-one input, column contracts, blank text, empty input and unchanged source frames. The final article code was extracted and reproduced in a disposable directory. Public code is compared to independently preserved hashes and executed again after publication.
This is an in-memory, exact-key join example. It does not verify generated-summary accuracy, account ownership, consent, permissions, tenant isolation, effective-date logic, large-data memory limits or downstream delivery. No model, real CRM, email service, customer dataset or production integration was tested. A wrong but unique client ID can still pass: the data contract controls shape and matching, not business truth.
Jason Ead publishes AlgoThoughts AI News and connects practical engineering with My Biz Heroes: websites, automation, workflows, AI tools and training. Explore Jason’s work. The robot magician cover is generated conceptual artwork; the interior table shows actual fixture behavior.
