translate is a table, keyed by ordinal¶
Level: 201 · for Python programmers
One line: str.maketrans('abc', 'xyz') returns {97: 120, 98: 121, 99: 122} — a plain dict of integers — and everything else surprising about translate follows from that: a value may be a string of any length or None, anything answering __getitem__ is a valid table, and the whole substitution is one pass, which three chained .replace() calls are not.
The docs describe str.translate(table) as "a string in which each character has been mapped through the given translation table", and then describe str.maketrans as a way to build one. Read in that order it sounds like a two-step ceremony around something replace already does. It is not, and the reason is visible the moment you print the table instead of passing it straight through:
Those are ordinals on both sides. 97 is ord('a'), 120 is ord('x'), and no character is stored anywhere in the result. That is the entire justification for maketrans existing: without it you would write {ord('a'): ord('x'), ord('b'): ord('y'), ord('c'): ord('z')} by hand every time, and you would get it wrong roughly as often as you write it. maketrans is also the only static method on str — you call it on the type rather than on a string, because it does not act on a string, it manufactures a dict.
Once you see that the table is just a dict you can index with an int, three things stop being special cases. A value may be a string of any length, or None, so one call can expand some characters and delete others. A table need only answer __getitem__, so a dict subclass with __missing__, a defaultdict, a list, or a class you wrote are all legal tables. And the lookup happens once per input character, on the original string, so no substitution can ever see another substitution's output — which is the one thing a chain of replace calls cannot promise.
Verified output of translate_is_a_table_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE TABLE IS A DICT OF INTEGERS
str.maketrans('abc', 'xyz') {97: 120, 98: 121, 99: 122}
type dict
97 is ord('a') and 120 is ord('x'). Both sides are ordinals:
the two-argument form never stores a character at all.
Written out by hand, the same table is:
{ord('a'): ord('x'), ord('b'): ord('y'), ord('c'): ord('z')}
That is the whole reason maketrans exists.
Three ways to build one, and what each returns:
maketrans('abc', 'xyz') {97: 120, 98: 121, 99: 122}
maketrans('abc', 'xyz', 'Q!') {97: 120, 98: 121, 99: 122, 81: None, 33: None}
maketrans({'a': 'HELLO', ...}) {97: 'HELLO', 98: None, 99: 'z'}
The third argument is not a third feature: it adds the same
keys with None as the value. And the one-argument form is the
only one that keeps values as strings -- it is barely a
conversion, it just turns any str keys into their ordinals.
2. A VALUE MAY BE A STRING OF ANY LENGTH, OR None
value table 'a-b-c' becomes
--------------------------------------------------------------
one char {97: 'A'} 'A-b-c'
many chars {97: 'ALPHA'} 'ALPHA-b-c'
empty str {97: ''} '-b-c'
None {97: None} '-b-c'
an ordinal {97: 65} 'A-b-c'
Expand and delete in the same call: {97: 'ALPHA', 45: None}
'a-b-c'.translate(...) -> 'ALPHAbc'
No other str method does both in one call. replace() can
grow a string, and replace('x', '') can shrink one, but a
call handles exactly one pattern. Here 'a' grew to five
characters and '-' vanished, in one walk over the input.
3. ONE PASS, WHICH IS WHAT CHAINED replace() IS NOT
Swap two characters. It cannot be done with replace at all:
'ab'.replace('a', 'b').replace('b', 'a') 'aa'
'ab'.translate(maketrans('ab', 'ba')) 'ba'
The first call's output is the second call's input, so every
'a' becomes 'b' and then every 'b' -- including the new ones --
becomes 'a'. Shift three letters by one and all three collapse:
'abc' through three replaces 'ddd'
'abc'.translate(maketrans(abc, bcd)) 'bcd'
The real-world shape of this is HTML escaping:
raw '<a>&'
.replace('<',..).replace('&',..) '&lt;a>&' <- doubly escaped
.replace('&',..).replace('<',..) '<a>&'
.translate(table) '<a>&'
Two replaces, two answers, and only one order is right. The
stdlib's own html.escape does it with chained replaces and
carries a comment on the first line saying it must go first.
With a table there is no first line: no character is ever
looked at twice, so there is no order to get wrong.
4. THE TABLE ONLY HAS TO ANSWER __getitem__
translate() indexes the table with an int and catches
LookupError. Nothing else is required -- not dict, not even
a mapping.
dict subclass, __missing__ -> '#' 'ab1 2c'
-> '### ##'
dict subclass, __missing__ -> None '4111-1111 1111 1111 (Visa)'
-> '4111-111111111111'
defaultdict(lambda: '') '4111-1111 1111 1111 (Visa)'
-> '4111111111111111'
a class with only __getitem__ 'abc'
-> 'AAAbc'
a 128-element list 'abc'
-> 'AAAbc'
The second and third do the same job two ways: keep a listed
alphabet, drop everything else, in one pass and with no regex.
That is the shape __missing__ is for -- 'map everything I did
not list to X' is one method, not a loop over the code space.
A LookupError from the table means 'leave this character
alone'. KeyError and IndexError are both LookupError, so both
mean identity; anything else propagates:
__getitem__ raises KeyError LookupError 'abc'
__getitem__ raises IndexError LookupError 'abc'
__getitem__ raises LookupError LookupError 'abc'
__getitem__ raises ValueError - raised ValueError
And that is why a str is a legal table and a silent no-op:
'abc'.translate('xyz') 'abc'
'xyz'[97] raises IndexError, which means 'leave it alone',
three times. No error, no change, no warning.
'abc'.translate('x' * 200) 'xxx'
Same call, longer string, and now the indexes land.
5. WHAT THE TABLE MAY NOT RETURN
A value is an int, a str, or None. Two failures, by type:
value a float TypeError
value a list TypeError
value 0x110000 ValueError
value -1 ValueError
An int value is a code point, so it must be one: the ceiling
is 0x110000 and there is no negative half of the code space.
maketrans has two of its own, both ValueError -- unequal
lengths in the two-argument form, and a str key that is not
exactly one character in the one-argument form.
maketrans('ab', 'xyz') ValueError
maketrans({'ab': 'x'}) ValueError
6. ABOVE THE BMP: ONE KEY PER CODE POINT
text 'x𝄞y' len 3
U+1D11E as an ordinal 119070 (0x1D11E)
translate({0x1D11E: 'CLEF'}) 'xCLEFy'
translate({0x1D11E: None}) 'xy'
maketrans(CLEF, 'x') {119070: 120}
keyed by the UTF-16 halves {55348: 'HI', 56606: 'LO'}
-> 'x𝄞y' <- nothing happened
A Python str is a sequence of code points, not of UTF-16 code
units, so an astral character is one key and one length-1
lookup. The surrogate ordinals 0xD834 and 0xDD1E are simply
not present in the string. In a language whose char is 16
bits this same substitution is two lookups and a pair to keep
together.
7. bytes.translate IS A DIFFERENT METHOD
bytes.maketrans(b'abc', b'xyz') bytes, len 256
bytes 95..100 of it b'_`xyzd'
Not a dict of the three keys you asked for: a 256-byte
lookup table, every byte value pre-filled with itself and
three of them overwritten. Byte 97 now holds 120.
b'abcd'.translate(bt) b'xyzd'
b'abcd'.translate(bt, b'd') b'xyz'
b'abcd'.translate(None, b'bd') b'ac'
Deletion is a second positional argument, not a None value,
because a byte table has no room for one. And the table can
be None on its own, which makes the call pure deletion --
a spelling str.translate does not have.
The two signatures side by side:
str .translate(table) table[ordinal] -> int|str|None
bytes.translate(table, delete=b'') a 256-byte sequence, or None
b'a'.translate(bytes(255)) ValueError
b'a'.translate({97: b'X'}) TypeError
'a'.translate(bytes(256)) '\x00'
A dict is not a bytes-like object, and 255 is not 256. The
last row is the one that surprises: bytes(256) is a valid
table for bytes and a legal, useless one for str -- 256 zero
bytes indexed at 97 gives 0, so every ASCII character maps to
the null character instead of raising.
What the run shows¶
The three ways to build a table are one way. maketrans('abc', 'xyz', 'Q!') is maketrans('abc', 'xyz') with two more keys whose value is None; the third argument is not a deletion feature, it is a shorthand for writing None twice. And the one-argument form barely converts anything — it takes a dict you already wrote and replaces any single-character str key with its ordinal, leaving the values exactly as they are. That is why it is the only form that can produce a table with string values: the two-argument form maps ordinal to ordinal and structurally cannot.
A value may be a string of any length, and None deletes. {ord('a'): 'ALPHA', ord('-'): None} turns 'a-b-c' into 'ALPHAbc' — five characters grown from one and two characters gone — in a single call. replace can grow a string and replace(x, '') can shrink one, but each call handles exactly one pattern, so "expand these and delete those" is at minimum two passes with replace and one with a table. An empty string as the value behaves identically to None; None is worth preferring because it says delete out loud and because it is what the third maketrans argument produces.
The one-pass property is the practical reason to reach for it, and it is easiest to see in a case replace simply cannot do: swapping two characters. 'ab'.replace('a','b').replace('b','a') is 'aa', because the second call's input is the first call's output and it cannot tell an original 'b' from a manufactured one. Shift three letters by one and all three collapse to 'ddd'. translate looks each character of the original string up exactly once, so there is no chain and no ordering.
The version of that bug people actually ship is HTML escaping. .replace('<','<').replace('&','&') turns '<a>&' into '&lt;a>&' — the ampersand that the first call introduced gets escaped by the second. Doing & first is correct, and the standard library knows it: html.escape is written as five chained replace calls with a comment on the first one saying it must be done first. That comment is load-bearing, it is a comment rather than a mechanism, and a translation table removes the need for it — with a table there is no first line.
Any object with __getitem__ is a table, which is more useful than it sounds. A dict subclass whose __missing__ returns None deletes everything you did not list, which is a whitelist filter in one pass and no regex: '4111-1111 1111 1111 (Visa)' through a table of digits and - comes back as '4111-111111111111'. A defaultdict(lambda: '') does the same job without a class. A list works too, because indexing a list with an int is exactly the interface required — a 128-element list is a perfectly good ASCII table. If you have ever written ''.join(c for c in s if c in ALLOWED), this is that loop, in C, with the condition in a dict.
A LookupError from the table means "leave this character alone." KeyError and IndexError are both subclasses of LookupError, so both mean identity; a ValueError propagates. That rule has one sharp consequence: a str is a legal translation table and usually a silent no-op. 'abc'.translate('xyz') returns 'abc' unchanged, because 'xyz'[97] raises IndexError — three times, quietly. Nobody writes that on purpose, but translate(x) where x was meant to be a table and is in fact a string is a bug that produces no error, no warning, and no change. (Make the string 200 characters and the indexes start landing, at which point it does something — just not anything you wanted.)
A code point above the BMP is one key, not two. U+1D11E is ordinal 119070, translate({0x1D11E: 'CLEF'}) finds it, and a table keyed by the UTF-16 surrogate halves 0xD834 and 0xDD1E matches nothing at all, because those ordinals are not in the string. A Python str is a sequence of code points, so this is the boring answer — and it is only boring here. In a language whose character type is 16 bits the same substitution is two lookups on a pair you have to keep together, which is the surrogate problem ↗ the sibling library owns.
bytes.translate is a different method that happens to share a name. It takes a 256-byte sequence — bytes.maketrans returns every byte value mapped to itself with your handful overwritten, not a dict of the keys you asked for — plus a second positional argument for deletion, because a byte table has no room for a None. b'abcd'.translate(None, b'bd') is pure deletion with no table at all, a spelling str.translate does not have. A 255-byte table is a ValueError and a dict is a TypeError, so most confusion between the two is caught. One is not: bytes(256) is a valid bytes table and a legal str table, and as a str table it maps every ASCII character to '\x00' rather than raising.
One aside worth recording, because this library has a rule about it. The ValueError message for a bad maketrans key reads "string keys in translate table" on Python 3.11 through 3.13 and "string keys in translatetable" on 3.14 — the space is gone. Nothing on this page prints an exception message for exactly that reason; CONTRIBUTING says an exception's message is not API, and this is a live specimen, found by running the examples across all four versions before recording the answer key.
The shell view: tr is the same idea with no ordinals¶
tr 'abc' 'xyz' is the two-argument maketrans with the translate already applied, and tr -d 'bd' is the third argument. The correspondence is close enough to be worth learning once — and it ends at exactly one place, which is the same place every shell text tool ends: tr operates on bytes. There are no ordinals in it because there are no characters in it.
$ printf 'Łódź\n' | tr 'ź' 'z' | xxd
00000000: 7a81 c3b3 647a 7a0a z...dzz.
what you asked for map ź to z
what tr received SET1 = {0xc5, 0xba}, SET2 = {0x7a} padded to {0x7a, 0x7a}
what it did both bytes of 'ź' became 'z' -> 'zz'
and 0xc5 is also the LEAD byte of 'Ł' -> 'z' + a stray 0x81
Python, same request
'Łódź'.translate(str.maketrans('ź', 'z')) -> 'Łódz'
One command, two different kinds of damage: the character you asked about became two zs because its two bytes were translated independently, and an innocent character lost its lead byte and left an orphaned continuation byte behind — the output is no longer valid UTF-8. str.maketrans('ź', 'z') is {378: 122}, one key, and the Ł is never consulted. The encodings library measures this whole family properly, including sort, uniq and cut: tr and sort work a byte at a time ↗. The one sentence to carry away is that tr has no flag for this — no locale setting and no option makes it character-oriented — so the pipeline answer to a non-ASCII substitution is not tr at all.
Where the two really are equivalent, they are worth swapping freely: an alphabet rename over ASCII is one tr or one translate, and it is exactly reversible because no bit moves. Base32 alphabets ↗ is the worked example — encoded.translate(str.maketrans(RFC, alphabet)) is the whole implementation of converting between RFC 4648, base32hex, Crockford and z-base-32, and that page uses it as the test for whether two schemes are really the same encoding.
The Rust view¶
There is no translate in Rust's standard library, and no maketrans. This is not a search that came up empty in a hurry: core::str and alloc::str between them expose replace, replacen, the four trim* families, to_lowercase, to_uppercase and the split and search iterators, and nothing that takes a character map. The idiom is chars() with a match, collected — which is fine, and which is also the reason the equivalent is worth writing down rather than looked for.
what you want Python Rust
------------------------------------------------------------------------------------------------------------
substitute a few characters s.translate(str.maketrans("abc","xyz")) s.chars().map(|c| match c {
'a' => "x", 'b' => "y",
'c' => "z", _ => …
}).collect::<String>()
expand and delete in one pass s.translate({97: "ALPHA", 45: None}) s.chars().flat_map(|c| match c {
'a' => "ALPHA".chars().collect(),
'-' => vec![],
o => vec![o],
}).collect::<String>()
delete a set of characters s.translate({ord(c): None for c in "-b"}) s.replace(&['-','b'][..], "")
swap two characters 'ba' "aa" <- same bug
s.translate(maketrans("ab","ba")) s.replace('a',"b").replace('b',"a")
shift three by one 'bcd' "ddd" <- same bug
s.translate(maketrans("abc","bcd")) s.replace('a',"b")
.replace('b',"c").replace('c',"d")
The bug transfers and the fix does not. Rust's chained replace collapses "abc" to "ddd" exactly as Python's does, for exactly the same reason — each call is a complete pass whose output feeds the next — and there is no table method to reach for instead. What Rust gives you instead is that str::replace takes a Pattern, so &['-','b'][..] is a character set in one call: "a-b-c".replace(&['-','b'][..], "") is "ac", which is tr -d semantics with none of tr's byte problem. That covers deletion of a set in one pass; it does not cover different replacements for different characters, and for that the answer really is chars().map(...) or flat_map, which is a match arm per character rather than a dict entry.
Two smaller notes. The match version is checked at compile time and the dict version is not, so a table with a typo in a key is a runtime no-op in Python and a non-exhaustive match in Rust. And Rust's char is 32 bits (why ↗), so the astral case behaves the same as Python's — one char, one arm — which is not true of the 16-bit-character languages.
If you are coming from ABAP¶
TRANSLATE text USING mask is the direct ancestor, and it is worth being precise about the three ways it differs, because two of them will bite. The mask is a flat string of pairs: TRANSLATE lv_text USING 'axbycz' reads as a→x, b→y, c→z, so the table is positional rather than keyed, and an odd-length mask is a defect the compiler will not tell you about. It works in place — the statement mutates its operand rather than returning a new value, which is the opposite of every Python str method — so a variable you were still using is gone. And it is one character in, one character out: there is no way to spell "delete this" and no way to spell "expand this to five characters", which are precisely the two things that make str.translate worth a page rather than a paragraph. Deletion in ABAP is a separate statement, REPLACE ALL OCCURRENCES OF ... WITH '' or CONDENSE for the whitespace case.
Two more differences to check against your own system rather than take from here. TRANSLATE ... TO UPPER CASE and TO LOWER CASE are the same keyword doing a completely different job, which has no Python parallel — str.upper() and str.translate() are unrelated methods. And ABAP's string is UTF-16 internally, so how TRANSLATE treats a character outside the BMP is a question worth answering empirically before you rely on it; Python's answer, measured above, is that it is a single key. Coming the other way, the habit that transfers cleanly is that a translation table is data — in ABAP it is usually a constant or a customizing table, and building str.maketrans output once at module level and reusing it is the same instinct and the same win. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- Print
str.maketrans('abc', 'xyz')andstr.maketrans({'a': 'x'})side by side. Explain, without running anything else, why one has anintvalue and the other has astrvalue — then predict whatstr.maketrans('a', 'x', 'a')gives, and check. - Write the whitelist filter twice: once as
''.join(c for c in s if c in ALLOWED)and once as adictsubclass with__missing__returningNone. Feed both a string containing an astral character and confirm they agree. Then decide which one you would rather read in six months. - Take the
html.escapechain and break it: write the fivereplacecalls in the wrong order and find the shortest input that shows the difference. Now write the table version and convince yourself no ordering exists to get wrong. 'abc'.translate('xyz')is a silent no-op. Build the shorteststrthat, used as a table, changes an ASCII input — and then work out what class of typo would produce it in real code.bytes(256)is a legal table for bothstrandbytesand does something different in each. Predict both results before running them, and then say which of the two you would call a bug in the language.- Reach for the
trcontrast: runprintf 'Łódź\n' | tr 'ź' 'z' | xxdand then decode the result in Python witherrors='replace'. How many characters were damaged, and how many did you name in the command?
See also¶
stripis a set, not a prefix — the otherstrmethod whose argument is not the thing it looks like- Counting characters — why an ordinal is a code point and what that does and does not count
stris notbytes— whybytes.translatehas a 256-entry table andstr.translatecannot- String literals —
\N{},\uand the other ways to write the key you are about to look up - What to write next — this page closes two questions; the method tour has more
- The crosswalk — which idea lives in which library
str.translatein the Python docs ↗ — andstr.maketrans↗ beside itbytes.translatein the Python docs ↗ — the different signature, in the same pagetrandsortwork a byte at a time ↗ — the shell equivalent, and why it damages UTF-8- Base32 alphabets ↗ —
str.translatedoing a real job, as the test for whether two encodings are the same one - UTF-16 and surrogates ↗ — what the astral row costs in a 16-bit-character language
- Replacing part of a string ↗ — Rust's
replaceandreplacen, the closest thing its std has