Strings from the inside: three widths, four layout cases, interning, and the price of concatenation
A string's object size at the same length is set by the highest character code in it: one Cyrillic letter among Latin ones doubles the room for every character, one emoji quadruples it. Two other questions follow from the same place: why `is` sometimes works for strings, and why `s += x` in a loop is sometimes linear and sometimes quadratic — the fast path has two conditions, and the second one is invisible in the code.
Full technical treatment
TL;DR
A string's size at the same length is set by its contents. The width is chosen by the highest character and applies to all of them at once: twenty Latin letters are 61 bytes, the same twenty with one Cyrillic or Greek letter are 98 — the switch below shows α, and both live in the same range, with one emoji 140.
There are four layout cases and three widths. ASCII is kept separately with a
shortened header — 40 bytes against 56 in this 64-bit build. That is why one é grows a string from
61 to 77 bytes without changing the bytes per character: the header grew.
"Interned" is four states, not one. A literal that looks like a name gets
interned; a string assembled at run time does not. Hence an is that works on
literals and breaks on the first string read from a file.
The fast concatenation path has two conditions, and the second is invisible.
The first is that the accumulator is a local variable. The second is that the
string has no extra references. A function with the line keep = s added gets
THE SAME opcode and runs a hundred and seventy-seven times slower.
join is not faster in this measurement — but it is more dependable. 0.6 ms against 0.8 ms, about
the same. The difference is that its time depends on none of the above.
One character sets the width
CPython, following PEP 393, stores a string in whichever of three widths suffices for its highest character. There is one width per string: a single emoji moves every character to four bytes, including all the Latin letters that would have fit in one.
The length is the same in all four positions of the switch — twenty. One character out of twenty changes.
There are three widths, and four cases visible in the size: ASCII lives in its own
structure with a 40-byte header instead of 56 (the numbers are from this 64-bit
build). An ASCII string's UTF-8 form is
byte-for-byte itself, so there is nothing to cache; any other string has the
fields for that cache. Hence one é growing a string from 61 to 77 bytes
without changing the bytes per character.
Over a million sixteen-character strings that is 54.4 mebibytes for Latin and 118.3 for emoji — the same information written in different widths.
Why is sometimes "works"
CPython keeps a table in which a string value exists in one copy, and puts some strings there by itself. Nowhere is it promised which ones.
Not every single character is created at start-up — only the one-byte ones:
chr(0x100) built twice gives two different objects, while chr(0xFF) gives the
same one.
What is observed: a literal that looks like a name gets interned; a literal with a space or punctuation does not; a string assembled at run time does not.
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
literal == built # True
literal is built # FalseThe danger is not that is gives a wrong answer but that it gives the right one
for as long as the strings come from the source. Code comparing strings with
is will pass every test written on literals and break on the first string from
a file, the network or a database — silently, answering False: "not the same
object", though == would have said True.
"ab" + "cd" is "abcd" is a separate case, and it gives True. That is not
interning but constant folding: adding two literals is done by the compiler, and
one constant remains in the bytecode. Replace the literals with variables and
is gives False.
Why concatenation is sometimes fast
"Strings are immutable, so concatenation in a loop is quadratic" is correct
reasoning with a wrong conclusion: an ordinary loop with s += x runs linearly,
because the string grows where it is.
There are two conditions for that. The first is the shape of the code: the accumulator must be a local variable of a function, and the result must be assigned straight back into it. The second is checked at run time: the string must have no extra references.
The second is the invisible one. Add the line keep = s to the loop — it
computes nothing — and the opcode stays exactly the same while twenty thousand
steps take 140.7 milliseconds instead of 0.8.
This is checked not by the ratio of times, which swings on this machine, but by the price of one step. On the fast path it does not move at all: 43, 42, 40, 39 nanoseconds as the length grows from 2500 to 20,000. On the slow path it grows with the length: 201, 442, 962, 4644. Every step copies everything already accumulated.
What to do about it
Reach for "".join(). In this measurement it is not faster than in-place
concatenation — 0.6 ms against 0.8, the same order — but it is more dependable:
its time does not depend on where the accumulator lives, on whether somebody kept
a reference, or on whether this code gets moved into a method tomorrow.
Do not compare strings with is. No version promises which strings get interned
automatically, and the boundaries of that behaviour have already moved between
3.12 and 3.13.
And keep the width in mind wherever strings are many: one character outside Latin costs not its own four bytes but four bytes for every character of the string.
A string's object size at the same length depends on what is inside it — and it depends SYSTEMATICALLY: on the highest character code, not on the object's history.
That contradicts what every other container teaches, which is why it slips past. A twenty-character string: twenty Latin letters are 61 bytes, the same twenty with one Cyrillic letter are 98, with one emoji 140. No character other than the replaced one changed.
A string is not the only such type, and that is worth saying up front so the
causes do not get mixed up later. An integer's size depends on the magnitude of
the number: 0 takes 28 bytes, 2**1000 takes 160. A list's size at the same
length depends on how the list was built: list(range(100)) is 856 bytes, while
the same length assembled with append is 920. What is different about a string is that this is neither history nor spare
room but a direct consequence of the contents: the same twenty characters in the
same alphabet always give the same number.
The article has three parts on the mechanism and a fourth with exercises. Each
of the three answers its own question. Why strings of
equal length take different amounts of memory. Why is sometimes works for
strings — and why it must not be relied on. And why s += x in a loop is
sometimes linear and sometimes quadratic: the fast path has two conditions, and
the second one is nowhere visible in the code.
Part I. Three widths and four layout cases
The width is chosen by the highest character
PEP 393 named both the problem and the answer in one sentence:
The Unicode string type is changed to support multiple internal
representations, depending on the character with the largest Unicode
ordinal (1, 2, or 4 bytes)
"With the largest ordinal" is the load-bearing part. The width is not chosen per character: there is one width per string, and the highest character decides it. One emoji in the middle of Latin text moves EVERY character to four bytes, including all the Latin letters that would have fit in one.
The length is the same in all four positions of the switch — twenty. One character out of twenty changes, and with it the width of all of them. The switch shows Greek α where the text above says Cyrillic: both live in the same range, and the run prints them side by side to show that they give the same 98.
The fourth case, and it is not a width
PEP 393 has three widths: one, two and four bytes per character. The cases
visible in the size of the object are four, and the fourth is not a width: an
ASCII string lives in a separate structure with a shorter header. From here on
"four" in this article means exactly that — four layout cases for a compact
string, not four widths. Measured by bench/strings/layout.py:
| Representation | Bytes per character | Header | What fits in this width |
|---|---|---|---|
| ASCII | 1 | 40 | Latin letters, digits, punctuation — U+0000…U+007F |
| Latin-1 | 1 | 56 | Accented letters, U+0080…U+00FF |
| UCS-2 | 2 | 56 | Cyrillic, Greek, Hebrew — up to U+FFFF |
| UCS-4 | 4 | 56 | Emoji and everything else above U+FFFF |
The first two rows differ not in the width of a character — both are one byte — but in the header: 40 against 56. Those sixteen bytes are two fields for a cached UTF-8 form, which an ASCII string does not have, because its UTF-8 is byte-for-byte itself.
Two different things are worth separating here, because they glue together
easily. The canonical representation — the one the string is stored in and the
one len counts — is one, two or four bytes per character. The cached UTF-8 is a
separate representation of the same string that may sit beside it once somebody
has asked for it. One byte per character in the first sense does not mean "the
string is stored as UTF-8": for Latin-1 the canonical byte and the UTF-8 byte are
different things, which is exactly why it has fields for the cache.
Hence the thing that surprises first: one é in a string of twenty Latin
letters grows it from 61 to 77 bytes without changing the bytes per character.
What grew was not the contents but the header.
"Quadruples", likewise, is about the character buffer rather than the size of the
whole object: getsizeof also covers the header and the terminating zero, so for
a short string the total does not grow exactly fourfold — 61 bytes against 140 at
twenty characters.
The run puts the same thing in absolute numbers: an empty string is 41 bytes, 'x' * 10 is 51 and 'é' * 10 is 67 — a
difference of sixteen, and all of it header. A single é must not be used for
this check: a lone one-character string is a static string with its UTF-8 form
already cached, and it gives 61 rather than 58.
The three widths and the "by the highest character" rule are part of PEP 393, accepted and implemented since 3.3. That may be treated as a property of the language in the sense in which a PEP is normative.
What the PEP does not fix is the specific 40 and 56. Those are the sizes of the structures in the build measured here: CPython, 64-bit — the run prints the word size on its first line. On another word size or in another build the numbers will differ, while the fact that an ASCII header is SHORTER by two fields will not.
And one more boundary worth knowing in advance: everything measured here lives
in Unicode code points, not in what the reader sees. len("é") is one if that
is a single code point and two if the same thing is written as a letter plus a
combining mark; the visible character is a grapheme cluster, and neither len
nor PEP 393 works with those.
How the width is established in the measurement
The script does not rely on reading a struct field alone: a field offset is an assumption, and a wrong number looks exactly like a right one. So the width is computed twice and both answers are printed side by side.
The first way is measurement, requiring no knowledge of internals:
per_char = sys.getsizeof(ch * 11) - sys.getsizeof(ch * 10)The second reads the kind field in the object header through ctypes. The run prints both and states on a separate line whether they agree.
On 3.12.3, 3.13.7 and 3.14.7 they did.
What it costs
A million short strings is an ordinary scale for a log, a cache or a parsed
file. This is what the choice of width means there — a million sixteen-character
strings, the strings themselves only, with no container around them. The same
run of bench/strings/layout.py prints it:
| Representation | Bytes per string | Total |
|---|---|---|
| ASCII | 57 | 54.4 MiB |
| Latin-1 | 73 | 69.6 MiB |
| UCS-2 | 90 | 85.8 MiB |
| UCS-4 | 124 | 118.3 MiB |
The first row and the last are the same information written in four different widths, and between them is more than double. And it is settled not by optimisation but by what characters the data contains: a string with a single emoji at the end costs what a string of nothing but emoji costs.
Part II. Interning
Four states, not two
"Interned or not" is the simplification that keeps the observations from adding
up. There are four states, listed in InternalDocs/string_interning.md — the
only InternalDocs file present in both 3.13 and 3.14:
| Field value | What it means |
|---|---|
| 0 | not interned |
| 1 | interned, mortal |
| 2 | interned and immortal |
| 3 | static: created when the interpreter starts |
The state can be checked two ways: through the private sys._is_interned
(which appeared in 3.13) and by reading the two low bits of the state field
in the object header. The run prints both.
Three of the four states go by the single word "interned", and that loses the
main point: interning and immortality are different things. The documentation of
sys.intern warns about it outright:
Interned strings are not immortal; you must keep a reference to the return
value of intern() around to benefit from it
So state 1 is "interned and mortal": the string is in the table but will go away
with the last reference to it. State 2 is "interned and immortal". State 3 is
"static" — created when the interpreter starts and owned by no code. From 3.14
onwards a separate function, sys._is_immortal, tells the first two apart; before
it, the only way was the field.
What gets interned on its own
Measured on 3.13.7 by bench/strings/interning.py. On 3.12.3 three of these
rows show a different state; the reason comes a few sections down:
| String | Field state | sys._is_interned |
|---|---|---|
| a literal that looks like a name | 1 | yes |
| a literal with spaces and punctuation | 0 | no |
| a literal of digits | 1 | yes |
| the empty string | 3 | yes |
| a single Latin-1 character | 3 | yes |
| a 4096-character literal | 1 | yes |
| assembled by join at run time | 0 | no |
What is visible here is not a rule of the language but the behaviour of this build: a literal that looks like a name gets interned. A space or an exclamation mark, and the string stays ordinary. The empty string does exist in one copy, created when the interpreter starts.
But "any single character exists in one copy" is wrong, and the boundary is not
where it is expected. Static single-character strings exist only for one-byte
characters. The run builds the characters at run time so that a literal and
constant folding cannot answer for it. The table was taken on 3.13.7; on 3.12.3
both is columns are the same while the state field reads zero for all seven
characters — the "static" state for single-character strings appeared in 3.13.
Block 6 of bench/strings/interning.py:
| Character | chr(n) is chr(n) | join is chr | Field state | sys._is_interned |
|---|---|---|---|---|
U+0061 'a' | True | True | 3 | yes |
U+00E9 'é' | True | True | 3 | yes |
U+00FF 'ÿ' | True | True | 3 | yes |
U+0100 'Ā' | False | False | 0 | no |
U+03B1 'α' | False | False | 0 | no |
| U+0416 (a Cyrillic letter) | False | False | 0 | no |
U+1F600 '😀' | False | False | 0 | no |
The last column is empty for a reason: sys._is_immortal only appeared in 3.14,
and the run was taken on 3.13.7.
The boundary falls exactly between U+00FF and U+0100. A Greek α built twice is
two different objects, and is on it gives False. So "a single character" is
about Latin-1, not about Unicode.
And the important one: a string assembled at run time was not interned in any of the cases checked. Hence the difference code breaks on:
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
literal == built # True
literal is built # FalseWhere is breaks silently
The danger is not that is gives a wrong answer. The danger is that it gives
the RIGHT answer for as long as the strings come from the source, and stops the
moment they come from anywhere else. Block 4 of the same run of
bench/strings/interning.py:
| Where the string came from | Field state |
|---|---|
| a literal in the source | 1 |
"user_id".encode().decode() | 0 |
They compare equal and are not identical. Code that compares strings with is
will pass every test written on literals and break on the first string that
arrives from a file, from the network or from a database. The failure does not
look like a failure: is simply answers False — "not the same object" — and a
branch quietly goes the wrong way, while == would have said True.
The language promises nothing here. All it says is that is compares identity:
The operators is and is not test for an object's identity: x is y is true
if and only if x and y are the same object
Not a word about equal literals yielding one object. Everything observed about that is implementation behaviour, and it has changed.
The same literal, different states
Here is direct evidence that the boundaries of "interned" get moved between
versions. The literal "some_attribute_name", the very same one:
| Version | interned field state |
|---|---|
| 3.12.3 | 2 — interned and immortal |
| 3.13.7 | 1 — interned, mortal |
| 3.14.7 | 1 — interned, mortal |
Nothing follows from this for code: the string is interned either way, and is
answers the same for two such literals. What does follow is that "interned" is
not one state but a family, and its internals are not something to build a
comparison on.
No version promises which strings get interned automatically. What is
promised is only what sys.intern does, and the promise is modest: Interning strings is useful to gain a little performance on dictionary lookup
. Everything else in this part is an observation on three builds, and it has to be phrased as "on 3.13.7 this string was not interned" rather than "strings like this are not interned".
Part III. Concatenation
Two conditions, not one
"Strings are immutable, so concatenation in a loop is quadratic" is correct
reasoning with a wrong conclusion: an ordinary loop with s += x runs linearly.
The reason is the BINARY_OP_INPLACE_ADD_UNICODE specialisation, and it has two
conditions.
The boundaries of this section first. Everything described here is a CPython optimisation, not a property of strings: immutability has not gone anywhere, and mutating a string in place is permitted precisely because nobody else is looking at it. No version promises this behaviour, and its conditions are narrow: a specific shape of code, a specific reference count, and a specific specialisation name that changes between releases. The numbers below were taken on 3.13.7 and the opcodes read on the same build.
The first is the shape of the code. The specialisation is chosen by what stands
as the NEXT instruction, and Python/bytecodes.c says so directly:
tier1 op(_BINARY_OP_INPLACE_ADD_UNICODE, (left, right --)) {
assert(next_instr->op.code == STORE_FAST);
PyObject **target_local = &GETLOCAL(next_instr->op.arg);
DEOPT_IF(*target_local != left);So the accumulator must be a local variable of a function, and the result must be assigned straight back into it. An accumulator in a list element or in an attribute does not qualify, and ordinary addition is what stays.
The second condition is checked at run time, and its purpose is named in the same file:
If left has only two references remaining (one from the stack, one in
the locals), DECREFing left leaves only the locals reference, so
PyUnicode_Append knows that the string is safe to mutate.
One line of code that does nothing
The second condition is what the second position of the switch above shows. Two functions:
def local_var():
s = ""
for _ in range(N):
s += X
return s
def extra_reference():
s = ""
for _ in range(N):
keep = s
s += X
return sThe difference is the line keep = s, which computes nothing and is passed
nowhere. The run of bench/strings/concat.py prints the opcode of all four functions
after warm-up, and for these two it is THE SAME:
| Function | Addition opcode after warm-up |
|---|---|
| accumulator in a local variable | BINARY_OP_INPLACE_ADD_UNICODE |
the same, plus keep = s | BINARY_OP_INPLACE_ADD_UNICODE |
| accumulator in a list element | BINARY_OP_ADD_UNICODE |
| accumulator in an attribute | BINARY_OP_ADD_UNICODE |
The time is not the same at all. In the same run the first function makes twenty thousand steps in 0.8 milliseconds and the second in 140.7.
Does the price of one step depend on what is already accumulated
On this machine quadratic behaviour cannot be checked by the ratio of times: the
ratio between neighbouring sizes swings from 4.4 to 9.7, and at lengths of
hundreds of kilobytes it most likely also contains a change in allocation
strategy, which the measurement does not separate.
Something else, though, can be checked cleanly — the price of ONE step, from the
same run of bench/strings/concat.py:
| N | Local variable, ms | ns per step | List element, ms | ns per step |
|---|---|---|---|---|
| 2500 | 0.11 | 43 | 0.50 | 201 |
| 5000 | 0.21 | 42 | 2.21 | 442 |
| 10000 | 0.40 | 40 | 9.62 | 962 |
| 20000 | 0.79 | 39 | 92.88 | 4644 |
The left "ns per step" column does not move: the length of the accumulator has
no effect at all on the price of a step. The right one grows with N — every
step copies everything already accumulated. That is the difference between "the
string grew where it was" and "the string was copied whole", shown rather than
named.
The numbers in this section come from one run of 3.13.7 and are compared only with one another. How fast the right column grows is not something the measurement claims: at large lengths it also contains a change in allocation strategy, and that is not separated here.
What to do about it
The comment in the source names what the optimisation is for:
This attempts to avoid quadratic behavior when one neglects to use str.join()
. That is, the fast path is a safety net for code written the
second-best way, not a licence to always write it that way.
Numbers from the same run:
| Approach | Twenty thousand pieces |
|---|---|
s += X, s a local variable | 0.8 ms |
"".join(pieces) | 0.6 ms |
StringIO.write | 0.7 ms |
In THIS measurement join wins little, and the win is not the point: 0.6 ms
against 0.8 is the same order. On other data the ratio comes out
differently: pieces of another length, another number of them, another alphabet,
and the gap can move either way — so neither "join is faster" nor "join is
slower" follows from here.
What does follow is that join is more DEPENDABLE. Its time does not depend on
where the accumulator lives, nor on whether somebody kept a reference to the
string, nor on whether this code gets moved into a method tomorrow. In-place
concatenation depends on all of that, and breaks silently.
Part IV. Practice
Practice · predict the output
import sys
left = "ab"
right = "cd"
one = "abcd"
two = left + right
three = "ab" + "cd"
print(one == two, one is two)
print(one == three, one is three)
print(sys.getsizeof("ab"), sys.getsizeof("αβ"))Practice · estimate
Version history
| Version | Change | What this means for your code |
|---|---|---|
| 3.12 | A literal that looks like a name is interned into state 2 — "interned and immortal". There is no way to read the state from Python: sys._is_interned does not exist yet, leaving only the field in the object header. | |
| 3.13 | sys._is_interned appears — private, but it is the interpreter's own answer. The same literal is now interned into state 1, "interned and mortal": interned strings have a reference count again. Nothing changes for code — what changes is that "interned" stops being a single state. | |
| 3.14 | The states are as in 3.13. The layout in all four cases is the same on all three versions checked: the run of bench/strings/layout.py on 3.12.3, 3.13.7 and 3.14.7 matches byte for byte apart from the first three lines, which carry the version, the compiler and the word size. |
How this was measured
The numbers in this article come from these scripts. Each opens from here, together with the record of its run.
Bytes and states — comparable across versions, recorded on all three:
Time — one record, one build:
The basis for the exercises in "Practice":
Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3); Intel Xeon 2.80 GHz, 2 vCPU. Bytes are comparable across versions — that is object layout. Time is not: the builds have different compilers and different flags.
Fragments of Python/bytecodes.c are quoted verbatim at tag v3.13.7.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
In fact
- It is proportional to the length times the width, and the width is set by the HIGHEST character in the string. Twenty Latin letters are 61 bytes; the same twenty with one
éare 77; with one Cyrillic letter, 98; with one emoji, 140. The length is twenty in all four cases. The practical consequence: a string with a single emoji at the end takes what a string of nothing but emoji of the same length takes. - There really are three widths: one, two and four bytes per character. But the cases visible in the size of the object are four: ASCII lives in a separate structure with a shortened header — 40 bytes against 56 in this 64-bit build — because an ASCII string's UTF-8 form is byte-for-byte itself and there is nothing to cache. Hence the first surprise: one
égrows a string of twenty Latin letters from 61 to 77 bytes WITHOUT changing the bytes per character. What grew was the header, not the contents. - For literals that look like names, yes — which is exactly why such code passes every test. Let a string arrive from somewhere other than the source and everything changes:
"user_id"read from a file or the network has state 0, is not interned, andisagainst the literal givesFalsewhile==givesTrue. The language promises nothing here: The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object — and not a word about literals. - It is
Truefor a different reason: adding two LITERALS is done by the compiler, not the interpreter, and one constant"abcd"remains in the bytecode. That is constant folding; interning only finishes the job. The check is to replace the literals with variables:left + rightwith the same values makesisgiveFalse. - There are four states, listed in
InternalDocs/string_interning.md: 0 — not interned, 1 — interned and mortal, 2 — interned and immortal, 3 — static. The boundaries between them get moved: the literal"some_attribute_name"has state 2 on 3.12.3 and state 1 on 3.13.7 and 3.14.7. Nothing follows from that for code — and that is the whole point: the internals of interning are not something to build a comparison on. - In an ordinary loop it is not: the price of one step does not move at all (43, 42, 40, 39 ns as
Ngrows from 2500 to 20,000), because the string grows where it is.Python/bytecodes.cnames the optimisation's purpose outright: This attempts to avoid quadratic behavior when one neglects to use str.join(). Where the fast path did not apply, the price of a step grows with the length: 201, 442, 962, 4644 ns — every step copies everything accumulated. How fast it grows is not something the measurement claims. - That is only the first of two conditions. The second is checked at run time — how many references the string has — and it fails independently of the first. A function with one line
keep = sadded inside the loop, doing nothing, gets THE SAME opcodeBINARY_OP_INPLACE_ADD_UNICODE, and runs in 140.7 ms against 0.8 ms. Neither the source nor the disassembled bytecode shows any difference. - It is not faster: 0.6 ms against 0.8 ms in the same run — that is, about the same. It is more DEPENDABLE. The time of
joindoes not depend on where the accumulator lives, nor on whether somebody kept a reference to the string, nor on whether this code gets moved into a method tomorrow. In-place concatenation depends on all of that, and breaks silently — no error, no warning, the same bytecode.
By version
- 3.12
- A literal that looks like a name is interned into state 2 — "interned and immortal". There is no way to read the state from Python:
sys._is_interneddoes not exist yet, leaving only the field in the object header.< - 3.13
sys._is_internedappears — private, but it is the interpreter's own answer. The same literal is now interned into state 1, "interned and mortal": interned strings have a reference count again. Nothing changes for code — what changes is that "interned" stops being a single state.<- 3.14
- The states are as in 3.13. The layout in all four cases is the same on all three versions checked: the run of
bench/strings/layout.pyon 3.12.3, 3.13.7 and 3.14.7 matches byte for byte apart from the first three lines, which carry the version, the compiler and the word size.<
What is covered
- Part I. Three widths and four layout cases
- The width is chosen by the highest character
- The fourth case, and it is not a width
- How the width is established in the measurement
- What it costs
- Part II. Interning
- Four states, not two
- What gets interned on its own
- Where `is` breaks silently
- The same literal, different states
- Part III. Concatenation
- Two conditions, not one
- One line of code that does nothing
- Does the price of one step depend on what is already accumulated
- What to do about it
- Part IV. Practice
- Version history
- How this was measured
Common misconceptions
A string's size is proportional to its length
It is proportional to the length times the width, and the width is set by the HIGHEST character in the string. Twenty Latin letters are 61 bytes; the same twenty with one é are 77; with one Cyrillic letter, 98; with one emoji, 140. The length is twenty in all four cases. The practical consequence: a string with a single emoji at the end takes what a string of nothing but emoji of the same length takes.
There are as many layout cases as widths — three
There really are three widths: one, two and four bytes per character. But the cases visible in the size of the object are four: ASCII lives in a separate structure with a shortened header — 40 bytes against 56 in this 64-bit build — because an ASCII string's UTF-8 form is byte-for-byte itself and there is nothing to cache. Hence the first surprise: one é grows a string of twenty Latin letters from 61 to 77 bytes WITHOUT changing the bytes per character. What grew was the header, not the contents.
Equal string literals always give one object, so is works for them
For literals that look like names, yes — which is exactly why such code passes every test. Let a string arrive from somewhere other than the source and everything changes: "user_id" read from a file or the network has state 0, is not interned, and is against the literal gives False while == gives True. The language promises nothing here: The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object
— and not a word about literals.
"ab" + "cd" is "abcd" is True because the result gets interned
It is True for a different reason: adding two LITERALS is done by the compiler, not the interpreter, and one constant "abcd" remains in the bytecode. That is constant folding; interning only finishes the job. The check is to replace the literals with variables: left + right with the same values makes is give False.
A string is either interned or it is not
There are four states, listed in InternalDocs/string_interning.md: 0 — not interned, 1 — interned and mortal, 2 — interned and immortal, 3 — static. The boundaries between them get moved: the literal "some_attribute_name" has state 2 on 3.12.3 and state 1 on 3.13.7 and 3.14.7. Nothing follows from that for code — and that is the whole point: the internals of interning are not something to build a comparison on.
Concatenating with s += x in a loop is always quadratic
In an ordinary loop it is not: the price of one step does not move at all (43, 42, 40, 39 ns as N grows from 2500 to 20,000), because the string grows where it is. Python/bytecodes.c names the optimisation's purpose outright: This attempts to avoid quadratic behavior when one neglects to use str.join()
. Where the fast path did not apply, the price of a step grows with the length: 201, 442, 962, 4644 ns — every step copies everything accumulated. How fast it grows is not something the measurement claims.
The fast concatenation path applies when the accumulator is a local variable
That is only the first of two conditions. The second is checked at run time — how many references the string has — and it fails independently of the first. A function with one line keep = s added inside the loop, doing nothing, gets THE SAME opcode BINARY_OP_INPLACE_ADD_UNICODE, and runs in 140.7 ms against 0.8 ms. Neither the source nor the disassembled bytecode shows any difference.
"".join() is needed because it is faster than concatenation
It is not faster: 0.6 ms against 0.8 ms in the same run — that is, about the same. It is more DEPENDABLE. The time of join does not depend on where the accumulator lives, nor on whether somebody kept a reference to the string, nor on whether this code gets moved into a method tomorrow. In-place concatenation depends on all of that, and breaks silently — no error, no warning, the same bytecode.
Check yourself
A hundred-character string, all Latin except the last one, which is an emoji. How many bytes per character does it spend?
Sources & further reading
5 SOURCES
- PEP 393 — Flexible String RepresentationPEP. The document that introduced the three widths. It states both the problem and the answer: “The Unicode string type is changed to support multiple internal representations, depending on the character with the largest Unicode ordinal (1, 2, or 4 bytes)”. It also lists the four structures — PyASCIIObject, PyCompactUnicodeObject and two non-compact forms — and explains what sets ASCII apart from other single-byte strings. Final since 3.3.https://peps.python.org/pep-0393/
- InternalDocs/string_interning.mdCPython source code. The only InternalDocs file present in both 3.13 and 3.14. It lists the four values of the interned field that the run reads: 0 — not interned, 1 — interned and mortal, 2 — interned and immortal, 3 — static. Without that list the two bits in the object header would read as a yes/no, and the difference between 3.12 and 3.13 would look like noise.https://github.com/python/cpython/blob/v3.13.7/InternalDocs/string_interning.md
- Python/bytecodes.c — the BINARY_OP_INPLACE_ADD_UNICODE specialisationCPython source code. Both conditions of the fast concatenation path, verbatim. The first is the shape of the code: `assert(next_instr->op.code == STORE_FAST);` and `DEOPT_IF(*target_local != left);`. The second is the comment about the reference count: “If `left` has only two references remaining (one from the stack, one in the locals), DECREFing `left` leaves only the locals reference, so PyUnicode_Append knows that the string is safe to mutate”. The purpose of the optimisation is named there too: “This attempts to avoid quadratic behavior when one neglects to use str.join()”. Read at tag v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/bytecodes.c
- sys.intern — what is promised and what is notOfficial documentation. The promise is about lookup: “Interning strings is useful to gain a little performance on dictionary lookup”. Nothing is promised about which strings get interned automatically — and that is exactly the question whose answer was changed between versions. Hence this article's rule: observed behaviour is reported with versions named, never as a rule of the language.https://docs.python.org/3/library/sys.html#sys.intern
- Data model: the is operatorOfficial documentation. Cited as a source of what the language does NOT guarantee: that equal strings are identical. All it says is that `is` compares object identity — “The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object”. Not a word about literals with equal contents yielding one object; everything observed about that is implementation behaviour.https://docs.python.org/3/reference/expressions.html#is