Two small facts. The second one is the reason most hand-written versions are off by exactly one.
print('the quick brown'.rsplit(' ', 1)) # -> ['the quick', 'brown']
# rsplit works from the right; the 1 means 'make at most one cut'
# so [0] is everything before the final word
limit = 10
print(len('the quick…')) # -> 10, not 9
# the ellipsis is one character and it counts,
# so only limit - 1 characters of text can survive
- rsplit with maxsplit gives you 'everything before the last word'
Splitting from the right and stopping after one cut divides the text into the part you keep and the fragment you are discarding. Without the 1 it splits everywhere and you have to rejoin, which is more work for the same answer.
- The ellipsis is part of the budget
If the caller says 10 characters, they mean the whole string fits in 10 columns — the … included. So the text can only use limit - 1. Getting this wrong is invisible in testing and shows up as a layout that wraps one character too far.
- … is one character, not three dots
The single ellipsis character U+2026 has length 1; typing '...' has length 3 and costs you two more characters of your budget. For a fixed width, pick one deliberately and count it correctly.
Deal with the easy case first: if the text already fits, return it untouched. Everything after that has to leave room for the ellipsis.
Take limit - 1 characters, because the ellipsis needs the last slot. Then ask whether that cut landed mid-word — look at the character you cut at — and if so, fall back to the last complete word with rsplit(' ', 1)[0].
Guard limit < 1. Return text when len(text) <= limit. Otherwise cut = text[:limit - 1]. If text[limit - 1] is not a space and cut contains a space, trim to cut.rsplit(' ', 1)[0]. Finally strip trailing spaces and add the ellipsis.
def truncate(text, limit): if limit < 1: raise ValueError(...) if len(text) <= ____: return text cut = text[:limit - ____] if not text[limit - 1].isspace() and ' ' in cut: cut = cut.____(' ', 1)[0] return cut.____() + '\u2026'
def truncate(text, limit):
if limit < 1:
raise ValueError(f"limit must be at least 1, got {limit}")
if len(text) <= limit:
return text
cut = text[:limit - 1]
if not text[limit - 1].isspace() and " " in cut:
cut = cut.rsplit(" ", 1)[0]
return cut.rstrip() + "\u2026"
cut is what you are keeping; the character just past it is what tells you whether the cut was clean. If that next character is a space, the last word ended exactly where you stopped and nothing needs trimming. Testing the wrong side of the boundary is what makes 'the quick brown' lose the word 'quick' for no reason.
`' ' in cut` stops a single long word being trimmed to nothing at all, and rstrip() removes the space that would otherwise sit awkwardly before the ellipsis. Each exists because a case here fails without it — which is the only good reason for a line of defensive code.
The contract promises an upper bound, not an exact length. Cutting back to a word boundary can lose several characters, and that is correct: layouts break when text is too long, never when it is too short. Being clear about which direction a constraint runs saves a lot of argument.
Truncate for display, never for storage — the shortened string is a rendering of the data, not the data. In a browser, CSS text-overflow: ellipsis does this at the real pixel width, which is more accurate than counting characters because a W is wider than an i. And character counting is a poor proxy for width in scripts with combining marks or in any proportional font, so treat this as a good approximation rather than a guarantee.
- Add a suffix parameter so the caller can use ' …' or ' (more)' instead, and make sure the budget arithmetic uses its real length rather than assuming one.
- Truncate on whole words from a word count rather than a character count, and decide what should happen to punctuation left stranded at the end.
- Make it safe for text containing emoji, then read about why len() and 'what the user sees as one character' are not the same thing.