Two ways to accept both a string and a list. They pass the same tests today and age completely differently.
# Two shapes, two cleaning paths - they will drift
def clean(value):
if isinstance(value, str):
return [p.strip().lower() for p in value.split(',') if p.strip()]
return [p.strip() for p in value] # forgot .lower(), forgot the blanks
# One narrowing step, then a single path
def clean(value):
chunks = [value] if isinstance(value, str) else list(value)
return [p.strip().lower()
for chunk in chunks
for p in chunk.split(',')
if p.strip()]
- Branch on the shape, not on the work
The first version decides what cleaning means twice, so the day someone adds case-folding they add it in one place and a bug appears in the other. The second version’s branch produces a list and then stops caring — everything after it is written once. Whenever you find yourself copying logic into an else, look for the narrowing step that would let both sides share it.
- A string is a sequence, which is the trap here
list('python') is ['p','y','t','h','o','n'], so a function that accepts 'either a string or a list of strings' and forgets to check will silently iterate one tag into six letters. That is why the isinstance test comes first, and why it wraps the string rather than converting it.
- Splitting the list items too is a decision, not an accident
['python, web'] could reasonably mean one tag or two. Choosing 'the same as the string would have meant' keeps one rule in the caller’s head instead of two, and it is written down in the contract, so it is a promise rather than a quirk.
Handle None first, then get everything else into the same shape as quickly as you can — one list of strings — and do all of the cleaning after that point, once.
If the value is a string, wrap it: [value]. If it is a list or tuple, use it as it is. Anything else is a TypeError. Then split every chunk on commas, strip and lowercase each piece, and drop the empty ones.
chunks = [value] if isinstance(value, str) else list(value), after checking isinstance(value, (list, tuple)) for the else. Check each chunk is a str before splitting it. Collect into a list, then dedupe in order with list(dict.fromkeys(tags)) — or keep a seen set as you go.
def normalise_tags(value): if value is ____: return [] if isinstance(value, ____): chunks = [value] elif isinstance(value, (list, tuple)): chunks = list(value) else: raise ____(f’cannot read tags from {type(value).__name__}') tags = [] for chunk in chunks: if not isinstance(chunk, str): raise ____('every tag must be a string') for piece in chunk.____(','): tag = piece.strip().____() if tag: tags.append(tag) return list(dict.____(tags))
def normalise_tags(value):
if value is None:
return []
if isinstance(value, str):
chunks = [value]
elif isinstance(value, (list, tuple)):
chunks = list(value)
else:
raise TypeError(
f"tags must be a string or a list of strings, got {type(value).__name__}"
)
tags = []
for chunk in chunks:
if not isinstance(chunk, str):
raise TypeError(
f"every tag must be a string, got {type(chunk).__name__}"
)
for piece in chunk.split(","):
tag = piece.strip().lower()
if tag:
tags.append(tag)
return list(dict.fromkeys(tags))
Six lines decide what chunks is; nothing after them ever asks again what the caller passed. Accepting a fourth shape later — a set, say — is one more elif and zero changes to the cleaning. That property is why the branch contains no work of its own.
Dictionaries have kept insertion order since Python 3.7, so list(dict.fromkeys(tags)) removes duplicates while preserving first-seen position, in one pass. set(tags) is shorter and throws the order away — and that order is the user’s, so it is not ours to discard. A seen set with a loop is equally correct and three lines longer.
It would be less code to call str() on whatever arrives and move on. Then a stray 7 becomes the tag '7', the database grows a row nobody can explain, and the traceback that would have pointed at the real bug never happens. A normaliser that accepts everything has not normalised anything — it has moved the problem downstream and made it quieter.
If the input genuinely only ever arrives in one shape, do not write this — accept the list and let a wrong caller crash. Functions that take several shapes have a real cost: every reader has to hold all of them, and the signature stops meaning much. This pattern earns its keep exactly once per boundary, at the edge where outside data enters, and is a smell anywhere deeper. And if you are already parsing the request with Pydantic or a form library, it has done this for you — a second normaliser is a second definition of the truth.
- Add a max_tags argument that raises when too many are given, and decide whether the limit applies before or after deduplication — the answer changes what the user sees.
- Allow a separator other than the comma, then work out why passing ' ' would break the existing 'python, web' inputs and what you would do about it.
- Return the removed duplicates alongside the clean list, so the caller can say 'you typed web twice' instead of silently swallowing it.