Deep Engineering
Advanced·Published·3.12 · 3.13 · 3.14·30 MIN

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

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:

RepresentationBytes per characterHeaderWhat fits in this width
ASCII140Latin letters, digits, punctuation — U+0000…U+007F
Latin-1156Accented letters, U+0080…U+00FF
UCS-2256Cyrillic, Greek, Hebrew — up to U+FFFF
UCS-4456Emoji 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.

language contract

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.

language contract

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:

PYTHON
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:

RepresentationBytes per stringTotal
ASCII5754.4 MiB
Latin-17369.6 MiB
UCS-29085.8 MiB
UCS-4124118.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 valueWhat it means
0not interned
1interned, mortal
2interned and immortal
3static: 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:

StringField statesys._is_interned
a literal that looks like a name1yes
a literal with spaces and punctuation0no
a literal of digits1yes
the empty string3yes
a single Latin-1 character3yes
a 4096-character literal1yes
assembled by join at run time0no

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:

Characterchr(n) is chr(n)join is chrField statesys._is_interned
U+0061 'a'TrueTrue3yes
U+00E9 'é'TrueTrue3yes
U+00FF 'ÿ'TrueTrue3yes
U+0100 'Ā'FalseFalse0no
U+03B1 'α'FalseFalse0no
U+0416 (a Cyrillic letter)FalseFalse0no
U+1F600 '😀'FalseFalse0no

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:

PYTHON
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
 
literal == built      # True
literal is built      # False

Where 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 fromField state
a literal in the source1
"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:

Versioninterned field state
3.12.32 — interned and immortal
3.13.71 — interned, mortal
3.14.71 — 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.

implementation detail · 3.13

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:

C
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:

PYTHON
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 s

The 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:

FunctionAddition opcode after warm-up
accumulator in a local variableBINARY_OP_INPLACE_ADD_UNICODE
the same, plus keep = sBINARY_OP_INPLACE_ADD_UNICODE
accumulator in a list elementBINARY_OP_ADD_UNICODE
accumulator in an attributeBINARY_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:

NLocal variable, msns per stepList element, msns per step
25000.11430.50201
50000.21422.21442
100000.40409.62962
200000.793992.884644

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.

measured observation

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:

ApproachTwenty thousand pieces
s += X, s a local variable0.8 ms
"".join(pieces)0.6 ms
StringIO.write0.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

Three equal strings obtained three ways, and two sizes. What does this code print on 3.13?
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

A string of twenty Latin letters, and the same string with one letter replaced by an emoji. How many times larger is the second one?
times

Version history

VersionChangeWhat this means for your code
3.12A 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.13sys._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.14The 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.

Common misconceptions

Claim

A string's size is proportional to its length

Actually

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.

Claim

There are as many layout cases as widths — three

Actually

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.

Claim

Equal string literals always give one object, so is works for them

Actually

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.

Claim

"ab" + "cd" is "abcd" is True because the result gets interned

Actually

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.

Claim

A string is either interned or it is not

Actually

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.

Claim

Concatenating with s += x in a loop is always quadratic

Actually

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.

Claim

The fast concatenation path applies when the accumulator is a local variable

Actually

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.

Claim

"".join() is needed because it is faster than concatenation

Actually

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

Question 1 of 6

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

  1. 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/
  2. 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
  3. 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
  4. 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
  5. 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