The same symbol behaves differently depending on what you feed it. + adds two numbers, joins two strings, and merges two lists. * multiplies numbers but repeats text. % computes a remainder on numbers and formats text on strings. This is not Python being inconsistent; it is operator overloading, and once you see it as a set of rules rather than a set of exceptions, a whole class of confusing bugs disappears.
Why does the same symbol do different things?
Every operator in Python is a request to call a method on the objects involved. When you write a + b, Python looks for __add__ on the left operand and calls it with the right operand. Integers define __add__ to add. Strings define __add__ to join. Lists define it to concatenate. Because the behaviour lives with the type, each type gets to decide what its operators mean, and a type that does not support an operation simply does not define the method.
print('int + int ->', 2 + 3)
print('str + str ->', 'ab' + 'cd')
print('list + list ->', [1, 2] + [3])
print('int * int ->', 2 * 3)
print('str * int ->', 'ab' * 3)
print('list * int ->', [0] * 3)
try:
print('ab' - 'cd')
except TypeError as error:
print('str - str ->', error)
The subtraction case shows the mechanism clearly: str does not define __sub__, so Python raises TypeError and tells you exactly which operation and which types were involved. Read that message as "strings do not support subtraction", not as a puzzle.
| Operation | On numbers | On strings |
|---|---|---|
+ | arithmetic addition | concatenation into a new string |
* | multiplication | repetition, and the count must be an integer |
% | remainder after floor division | old-style formatting |
< | numeric order | character-by-character order |
What happens when you mix a string and a number?
You get a TypeError, and the error message is unusually instructive because it prints both operand types.
try:
print('5' + 5)
except TypeError as error:
print('mixed + failed ->', error)
print('but "5" * 5 works ->', '5' * 5)
try:
print(sum(['1', '2', '3']))
except TypeError as error:
print('sum of strings failed ->', error)
values = ['1', '2', '3']
print('converted total ->', sum(int(value) for value in values))
The '5' * 5 line is the one that produces the most confusion, because it succeeds. Multiplication on a string means "repeat", and repetition needs a count, so '5' * 5 is '55555'. The rule is not that strings and numbers mix; it is that each operator has a different meaning on each type, and only some combinations are meaningful.
The practical habit is to convert at the boundary. Data arrives as text, so convert once, at the point it enters your program, and work with real numbers everywhere else. The generator expression in the last line does exactly that: it converts each element and hands the numbers to sum().
There is a subtlety in that conversion when the text is not a clean number. int() refuses anything with a decimal point in it, so a value like '3.5' raises ValueError rather than quietly becoming 3. That refusal is a feature, because it stops a decimal quantity from being stored as an integer and losing its fractional part.
How does the % operator work on strings?
On a string, % is printf-style formatting, inherited from C and long predating f-strings. Placeholders are written in the string and values are supplied after it. When there is more than one placeholder, the values must be passed as a tuple.
name = 'Ada'
count = 3
share = 0.256
print('%s owns %d files (%.1f%%)' % (name, count, share * 100))
print('one value ->', '%s is ready' % name)
try:
print('%d' % '5')
except TypeError as error:
print('percent with a string failed ->', error)
try:
print('progress: 50%' % ())
except ValueError as error:
print('literal percent failed ->', error)
print('escaped percent ->', 'progress: 50%%' % ())
Two traps live here. The first is that %d insists on a real number, so a string that happens to look numeric still fails. The second is the literal percent sign: % starts a placeholder, so writing 50% inside a formatted string breaks, and %% is how you print one.
This style still appears in older codebases and in logging libraries, so it is worth recognising. For anything new, reach for f-strings instead: they are faster, they do not need a tuple, and the expression sits right next to the text it produces.
One more difference between the two styles catches people out. In printf-style formatting, the conversion codes decide how a value is presented: %s for text, %d for a whole number, %f for a decimal, and %.2f for a decimal with two places. The type check happens at the point of formatting, which means a value that arrives as text but should be a number fails loudly. That is a useful property when your data is messy, and it is one of the reasons the style survived so long in logging code.
How do comparisons behave for text?
Comparing two strings does not compare them numerically or alphabetically. It compares them character by character using each character's Unicode code point, so uppercase letters sort before lowercase ones and digits sort before letters.
print('"apple" < "banana" ->', 'apple' < 'banana')
print('"Zebra" < "apple" ->', 'Zebra' < 'apple')
print('"10" < "9" ->', '10' < '9')
print('10 < 9 ->', 10 < 9)
names = ['apple', 'Banana', 'apricot', 'Cherry']
print('plain sort ->', sorted(names))
print('casefold sort ->', sorted(names, key=str.casefold))
Two consequences matter in practice. Sorting text that contains numbers gives you '10' before '9', because you are comparing characters rather than quantities. And sorting mixed-case text puts every capitalised word before every lowercase one, which looks wrong to a human reader. Passing key=str.casefold fixes the second problem, because it compares a normalised copy of each string instead of the original.
Your turn: build a safe text-to-number adder
- Write a function
add_values(raw_a, raw_b)that takes two strings, converts both withint()orfloat(), and returns their sum. Decide up front whether it handles decimals, and say so in a docstring. - Run it with
'42'and'8'. It must print 50 and never raise. - Now call it with
'42'and'eight'. CatchValueErrorand print a message naming which argument failed, using the variable names rather than the raw values, so a password never ends up in your logs. - Add a variant that handles values with units, such as
'12kg', by stripping the unit before converting. Write down the rule for what you accept and what you reject. - Finish by writing the safe version of the accidental success you met earlier: a function that takes a digit string and a count and returns the digit repeated, and one that raises a clear error if the count is not a non-negative integer.
Rules of thumb
- Every operator asks the operand types what the operation means, so the same symbol can have several meanings.
+adds numbers, joins strings and merges lists;*multiplies numbers and repeats strings.- A
TypeErrormessage names both operand types; read it as a statement about which operation that combination of types does not support. - Convert text to numbers once, at the boundary, instead of scattering conversions through your logic.
- Text comparison is character-by-character, so sort numbers as numbers and use
key=str.casefoldfor human-facing name lists.