Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·35 MIN

Strings and formatting: immutability, slices, f-strings and the cost of concatenation

Questions about strings check whether you understand that a string is immutable and what follows from that. A run answers: s[0] = 'H' gives a TypeError; after t = s; s += '!' the name t still sees the old string; slice bounds are clipped to the string's length — 'Python'[10:20] is empty, 'Python'[:100] is whole, while an index beyond it is an IndexError; f'{2.5:.0f}' prints 2, because rounding goes to even; a six-letter Cyrillic word is six code points and twelve bytes; and CPython's fast path for s += x does not always apply: an extra reference, a computed hash, a str subclass, a non-ASCII character and — on 3.13 and 3.14 — a second statement on the same line of code switched it off. join is the reliable way to build a string.

Full technical treatment

TL;DR

A string in Python is an immutable sequence of Unicode code points (a code point is one number in the Unicode table; usually it is one letter, but not always). A character cannot be replaced: s[0] = 'H' gives a TypeError. Methods — upper, replace, strip — return a result, and the original string does not change. And s += '!' binds s to a new value: a name t taken before still sees the old one.

Hence the main consequence: build a string from many pieces with "".join(parts). The documentation calls repeated concatenation quadratic. CPython has a fast path that sometimes extends the string in place, but its conditions are an implementation detail, and in the run many things switched it off: a second reference to the string (at twenty thousand pieces concatenation cost 83 times more per piece than join), a computed hash, a str subclass, a non-ASCII character, and on 3.13 and 3.14 even a second statement on the same line of code.

Beyond that comes what separates understanding from having read about it. A slice does not fail: its bounds are clipped to the string's length — 'Python'[10:20] is empty, while 'Python'[:100] is whole. f'{2.5:.0f}' prints 2: rounding to even. The length of a string is in code points, while in UTF-8 it is in bytes: a six-letter Cyrillic word is six code points and twelve bytes, and the letter é written as 'e\u0301' is two code points. And f-strings have changed: quotes of the same kind inside an expression are allowed only since 3.12, and since 3.14 there are t-strings.

Runs: CPython 3.11.15, 3.12.3, 3.13.7 and 3.14.7 on Linux x86_64; checked on 14.10.2026. Python 3.15, released by that date, was not run by the lesson.

Where to start
Before this lesson it is enough to understand
  • how to create a string and print it;
  • what the index of a sequence element is;
  • that a name in Python is a reference to an object, and two names can refer to one object; in detail — the lesson "is versus ==";
  • what O(n²) means — the lesson "Complexity of operations"; for the "Deeper" section — what an object's hash is: the lesson "Built-in collections".
You do not need to know in advance
  • string immutability, slices, split, join, find versus index;
  • format specifications, the debug =, rounding to even;
  • str and bytes, the cost of += in a loop, f-strings in 3.12 and t-strings in 3.14.

Base: a string is an immutable sequence

A string is indexed, sliced and iterated like a list, but it cannot be changed. The documentation says so about such types directly:

An object of an immutable sequence type cannot change once it is created.

— The data model — immutable sequences

A junior-level answer to "what is a string in Python": an immutable sequence of Unicode characters — more precisely, code points; methods return a result and do not change the original string; it is formatted with f-strings. That is correct, and it is followed by questions: what does += do then, why can concatenation in a loop be slow, how does find differ from index, and how many bytes does a string take. The mechanisms below answer them with a run.

How to read the code blocks: a block marked as a full file can be copied as is; a block without a language is the output of a run, in which this English version translates the captions, while program strings, identifiers and error texts stay as in the run (where a program string is translated, the block says so); the Russian original of the output is in bench/strfmt/runs/.

First, a full short example: it can be copied into an empty directory and run. The full file bench/strfmt/example.py (its one comment translated), run with python3 bench/strfmt/example.py:

PYTHON
import unicodedata
 
s = "cafe"
t = s
s += "\u0301"  # a combining acute accent: together with e it renders as é
print(ascii(s), t, s is t)
print(len(s), len(s.encode("utf-8")), len(unicodedata.normalize("NFC", s)))
print(repr("Python"[10:20]), repr("Python"[:100]))
print("-".join(["a", "b", "c"]))

The output, the same on CPython 3.11–3.14:

'cafe\u0301' cafe False
5 6 4
'' 'Python'
a-b-c

t sees the old value; s holds five code points and six UTF-8 bytes, although the screen shows four letters — after NFC normalization (merging the letter and the mark into one code point) there are four; a slice entirely beyond the string is empty, while one that goes partly beyond is clipped; join glues the pieces with the separator. Each line is taken apart below.

Mechanism 1: immutability and +=

language contractImmutability is the data model; the output is a run of bench/strfmt/basics.py, sections 1 and 7, identical on CPython 3.11–3.14 (Linux x86_64). That a method returned the same object is an observation of the CPython implementation, not a contract.

Output; the captions are translated, and so are the program strings: the script uses the Russian word for "hello", shown here as 'hello' — the section on str and bytes below keeps the original literal.

1) immutability
   s[0] = 'H'          -> TypeError: 'str' object does not support item assignment
   s.upper()           -> 'HELLO' | s -> 'hello'
   t = s; s += '!'     -> s = 'hello!' | t = 'hello' | s is t: False

Three lines — three consequences:

  • A character cannot be replaced. To "change the first letter" you build a new string: 'H' + s[1:].
  • Methods return a result, the original string does not change. s.upper() does not touch s; the result has to be kept: s = s.upper().
  • s += '!' is a new value and a rebinding of the name. t, taken before, points to the old string. The same as assigning any immutable object. A caveat: if nothing but s refers to the old string, CPython may extend it in place (see "Deeper") — but no one is left to notice the change.

Immutability promises that a value someone sees will not change. It does not promise that every method creates a new object. Section 7 of the same run (added on 14.10.2026; output, captions translated), where p = 'Python':

7) slice bounds, code points, the same object
   p.strip() is p                                -> True
   p.replace('z', 'y') is p                      -> True
   p[:100] is p                                  -> True
   p.upper() is p.upper()                        -> False

When the result matched the original string, CPython 3.11–3.14 returned the same object; when it did not, a new one. This is an observation of the implementation, not a rule of the language: is cannot be relied on for strings.

Mechanism 2: slices and indices

language contractThe rules for indices and slices are the sequence documentation; the output is a run of bench/strfmt/basics.py, sections 2 and 7, identical on CPython 3.11–3.14.
2) slices
   p[0]      -> 'P'
   p[-1]     -> 'n'
   p[1:4]    -> 'yth'
   p[:2]     -> 'Py'
   p[-3:]    -> 'hon'
   p[::2]    -> 'Pto'
   p[::-1]   -> 'nohtyP'
   p[10:20]  -> ''
   p[10]     -> IndexError: string index out of range

The rules checked in an interview:

  • The right bound is not included. p[1:4] is characters 1, 2 and 3; the slice's length is 4 − 1.
  • A negative index counts from the end. p[-1] is the last character, p[-3:] the last three.
  • p[::-1] returns the reversed string, the original does not change. It is code points that get reversed, not visible letters — for text with combining marks this breaks the text (Mechanism 5).
  • A slice forgives, an index does not. Slice bounds are clipped to the string's length: p[10:20] lies entirely beyond it and gives an empty string, while p[10] gives an IndexError. A slice that goes partly beyond is not empty:
7) slice bounds, code points, the same object
   p[:100]   -> 'Python'
   p[4:100]  -> 'on'
   p[-100:2] -> 'Py'

The picture above is a teaching ASCII case: in 'Python' every cell is both a code point and a visible letter. For arbitrary text that is not so.

Mechanism 3: the methods people ask about

language contractThe output is a run of bench/strfmt/basics.py, section 3, identical on CPython 3.11–3.14.
3) methods
   'a,b,,c'.split(',')               -> ['a', 'b', '', 'c']
   '  a  b '.split()                 -> ['a', 'b']
   '-'.join(['a', 'b', 'c'])         -> 'a-b-c'
   '-'.join([1, 2])                  -> TypeError: sequence item 0: expected str instance, int found
   '  hi  '.strip()                  -> 'hi'
   'abc'.find('z')                   -> -1
   'abc'.index('z')                  -> ValueError: substring not found
   'hello'.replace('l', 'L', 1)      -> 'heLlo'
   'hello world'.title()             -> 'Hello World'
   '42'.isdigit(), '-42'.isdigit()   -> (True, False)

Four places where the answers differ from expectation:

  • split(',') and split() are different operations. With a separator, empty pieces stay: 'a,b,,c' gives four elements. Without an argument the string is split on whitespace characters, consecutive ones count as one separator, and there are no empty pieces.
  • join is a method of the separator, and accepts only strings. '-'.join([1, 2]) fails; numbers are first turned into strings: '-'.join(map(str, nums)).
  • find versus index. Not found — find returns −1, index raises ValueError. The check if s.find(x): is a bug: −1 is truthy, and 0 is falsy.
  • isdigit() does not check "this is a number". For '-42' it is False. It is more reliable to try int(s) and catch ValueError.

Mechanism 4: formatting

language contractFormat specifications and the float rounding rule are the documentation; the history of f-strings is the 3.14 language reference. The output is a run of bench/strfmt/basics.py, sections 4 and 6; section 4 is identical on CPython 3.11–3.14, section 6 differs by version.

Output; the captions are translated, and so is one program string: the script uses a Russian name, shown here as "Ann" — the widths of the padded results are those of a three-letter name.

4) formatting
   f'{x:.2f}'          -> '3.14'
   f'{n:,}'            -> '1,234,567'
   f'{n:_}'            -> '1_234_567'
   f'{name:>6}|'       -> '   Ann|'
   f'{name:*^9}'       -> '***Ann***'
   f'{name!r}'         -> "'Ann'"
   f'{x=}'             -> 'x=3.14159'
   f'{x=:.2f}'         -> 'x=3.14'
   f'{0.25:.0%}'       -> '25%'
   '{} and {}'.format(1, 2) -> '1 and 2'
   '%s: %d' % ('n', 5) -> 'n: 5'
   f'{0.5:.0f} {1.5:.0f} {2.5:.0f}' -> '0 2 2'
   round(2.5), round(3.5) -> 2 4
   Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP) -> 3
   f'{2.675:.2f}', round(2.675, 2) -> '2.67' 2.67
   Decimal(2.675) -> 2.67499999999999982236431605997495353221893310546875

After the colon in an f-string comes the format specification: precision (.2f), the thousands separator (, or _), alignment and fill (>6, *^9), percentages (.0%). !r substitutes repr instead of str — with quotes. f'{x=}' prints both the name and the value — handy for debugging, added in 3.8. str.format understands the same specification after the colon. % has its own, narrower printf-style syntax: '%s: %d' works, but there is no thousands separator, fill or centring. In an f-string the expression stands right where its value will end up.

The last lines are a trap. f'{2.5:.0f}' is 2, not 3, and round(2.5) is 2 too: a number exactly halfway is rounded to even, and the documentation says outright that formatting a float rounds the same way as round(). "Exactly halfway" means in binary: 2.5 is represented exactly, 2.675 is not. The last two lines of the run show that this literal stores 2.67499…, so both f'{2.675:.2f}' and round(2.675, 2) give 2.67 — rounding to even has nothing to do with it. That is not a bug but the rule; if you need "school" rounding of halves up, it is done with the decimal module and an explicit ROUND_HALF_UP mode.

Versions. In 3.12 some restrictions were lifted from f-strings. Inside an expression you can now use quotes of the same kind as outside:

6) versions: what compiles
   f"{d["k"]}"   -> '1'
   f"{d['k']}"   -> '1'
   t"x={1 + 1}" -> SyntaxError: invalid syntax

This is the 3.13 output. On 3.11 the first line is SyntaxError: f-string: unmatched '[': code written for 3.12 will not compile on 3.11. And on 3.14 the third line compiles — it is a t-string, a template literal — and gives a Template object, not a string: the expression inside is evaluated at once — on 3.14 the run prints that the value is already computed, 2 — but it is put into text not by the language but by the code that processes the template.

Mechanism 5: str and bytes

language contractThe output is a run of bench/strfmt/basics.py, sections 5 and 7, identical on CPython 3.11–3.14. UTF-8 by default in 3.15 is a release-notes quote, not a run.

The input here is the real program literal from the run — Russian for "hello", six Cyrillic letters; it is kept as in the run so that the lines can be copied and give 6, 12 and 208. Output; the captions are translated, the program strings are not:

5) str and bytes
   'hello'    len = 5, bytes in UTF-8 = 5
   'привет'   len = 6, bytes in UTF-8 = 12
   '👍'        len = 1, bytes in UTF-8 = 4
   'привет'.encode()[0]  -> 208 — a byte, it is an int, not a character
   b.decode()            -> 'привет'
   'привет'.encode('ascii') -> UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-5: ordinal not in range(128)
   'a' + b'b'            -> TypeError: can only concatenate str (not "bytes") to str

str is text made of Unicode code points, bytes is a sequence of bytes. You go between them explicitly: encode turns text into bytes in a chosen encoding, decode goes back. Three consequences follow: len of a string counts code points, not bytes — a Cyrillic letter takes two bytes in UTF-8, '👍' four; an element of bytes is a number, not a character; str and bytes cannot be mixed in one operation.

A code point is not the same as a visible letter. Section 7 of the run; inputs with non-ASCII characters are written as escape sequences, FAMILY is the "family" emoji of four people (output, captions translated):

7) slice bounds, code points, the same object
   FAMILY = '\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466'
   len('\u00e9')                                 -> 1
   len('e\u0301')                                -> 2
   len(unicodedata.normalize('NFC', 'e\u0301'))  -> 1
   len('\U0001F44D\U0001F3FD')                   -> 2
   len(FAMILY)                                   -> 7
   ascii('cafe\u0301'[::-1])                     -> '\u0301efac'

The letter é can be one code point ('\u00e9') or two — e and the combining acute accent U+0301; on screen they look the same, but len differs. unicodedata.normalize('NFC', …) turns such a pair into one code point. A thumbs-up with a skin tone is two code points, the "family" is seven: four people and three invisible joiners U+200D. And [::-1] rearranges code points: in the reversed 'cafe\u0301' the accent came first and no longer sits over the e. So len is not the number of visible characters, and [::-1] is not a reversal of arbitrary text.

Bytes are what goes into a network and into a file in binary mode, and for a text file the encoding is worth naming explicitly: open(path, encoding="utf-8"). Without it, on 3.11–3.14, as open.__doc__ says, "The default encoding is platform dependent". In Python 3.15, according to the release notes, "Python now uses UTF-8 as the default encoding, independent of the system's environment"; PYTHONUTF8=0 or -X utf8=0 restores the previous behavior. The lesson was not run on 3.15. An explicit encoding does not become obsolete because of this: the same code also runs on versions before 3.15.

Deeper: the cost of concatenation and CPython's fast path

measured observationbench/strfmt/cost.py, section 1, and bench/strfmt/fastpath.py: medians, CPython 3.13.7 on Linux x86_64, one machine (2 cores); 3.11.15, 3.12.3 and 3.14.7 are in the records with a version suffix. The code-shape requirement of the fast path is Python/bytecodes.c. These are observations of one configuration: on another machine and version the numbers, and even the set of conditions, will differ — measure on your own.

The documentation warns directly:

Concatenating immutable sequences always results in a new object. This means that building up a sequence by repeated concatenation will have a quadratic runtime cost in the total sequence length.

— Built-in types — common sequence operations, note 6

The measurement: n pieces of ten characters each, the time per piece. For linear concatenation it does not depend on n; if it grows with n, that is the sign of quadratic concatenation:

1) concatenating n pieces of ten characters, ns per piece, medians of 7 rounds
   form                                2000     20000     ×10
   "".join(parts)                      49.8      35.5     0.7
   s += x                              82.9      52.5     0.6
   s += x, with a second reference    215.7    2942.0    13.6
   io.StringIO                         42.0      49.4     1.2

At these sizes the time per piece of join and io.StringIO did not grow with n — as the documentation promises. But neither did that of a plain s += x in a function. The reason is a fast path in the CPython interpreter: when it can, it extends the string in place rather than copying it. The sources name the goal of this path: "This attempts to avoid quadratic behavior when one neglects to use str.join()".

Immutability does not contradict this. It promises that a value someone sees will not change; only a string that nobody but the accumulator sees can be extended in place. Reusing memory is the implementation's right.

As soon as a second reference to the same string appears — in the measurement it is one extra assignment alias = s in the loop body — extending in place is no longer possible: each += copies everything accumulated so far. The time per piece grew 13.6 times for ×10 pieces (9.9 on 3.14.7); at twenty thousand that is 83 times more than join.

The fast path has more than two conditions. The comment in Python/bytecodes.c names the shape of the code — the result of += is stored into the same local variable — and the number of references. A separate run, bench/strfmt/fastpath.py, shows that this is not enough. One step of s += x on a string of a million characters: a fraction of a microsecond means it was extended in place, hundreds of microseconds mean a million characters were copied (3.13.7 output, a fragment; captions translated):

2) one step of s += x on a string of 1 000 000 characters, µs, median of 25 steps
   nothing extra                          0.1
   a second reference exists            288.4
   hash(s) computed before the step     275.9
   s = sys.intern(s) before the step    253.3
   s is an instance of a str subclass   268.6
   ASCII string += ASCII piece            0.3
   ASCII string += piece with 'é'       108.5
   string of 'é' += ASCII piece           0.1

A copy came from: a second reference; a hash already called for the string (for example, by using the string as a dictionary key); sys.intern — putting the string into the shared table of interned strings, that is, strings kept in a single instance (the hash is computed along the way, so the run does not separate these two cases); a str subclass; an ASCII string to which a non-ASCII character was appended — its internal representation changes. The picture is the same on 3.11–3.14.

The shape of the code, however, depends on the version. The same loop written on one line with ; became quadratic on 3.13.7 (output, captions translated):

1) concatenating n pieces of ten characters, ns per piece, medians of 7 rounds
   form                                  2000     20000     ×10
   s += x                                37.1      36.3     1.0
   s += x; k += 1  (one line)           184.1    5942.0    32.3
   s += x and k += 1 (two lines)         50.5      53.6     1.1
3) the += opcode after warm-up and the instruction that follows it
   own_line   BINARY_OP_INPLACE_ADD_UNICODE    -> STORE_FAST
   one_line   BINARY_OP_ADD_UNICODE            -> STORE_FAST_LOAD_FAST
   two_lines  BINARY_OP_INPLACE_ADD_UNICODE    -> STORE_FAST

The specialization that extends in place requires the += to be followed immediately by a store into a local variable — in Python/bytecodes.c (line 540 at tag v3.13.7) it is assert(next_instr->op.code == STORE_FAST);. On a single line the 3.13 compiler merged the store into s and the load of k into one instruction, STORE_FAST_LOAD_FAST, and the interpreter chose ordinary string addition. On 3.14.7 it is the same (×35.5), while on 3.11.15 and 3.12.3 the one-line form is linear (×1.0 and ×1.1). An accumulator in an object attribute or a global variable also fails the code-shape condition — those cases are measured in the article "Strings from the inside".

Hence the interview answer: build a string from many pieces with "".join(parts) or io.StringIO; that is the documentation's advice, and it depends neither on the version nor on the shape of the code. The documentation describes repeated concatenation as quadratic; CPython often extends the string in place, but the full set of conditions is a detail of a particular version and cannot be relied on. How to check the fast path on your own code is covered in the same article.

How to answer in an interview

Short answer: a string is immutable — methods and += return a result, and the original string does not change. Slices s[i:j:k] do not include the right bound, negative indices count from the end, slice bounds are clipped to the string's length — so a slice entirely beyond it is empty. Format with f-strings with a specification after the colon. A string is built from many pieces with "".join(...): the documentation calls repeated concatenation quadratic, and CPython's fast path is a conditional optimization. str is Unicode code points, bytes is bytes, between them — encode and decode.

That is enough for a correct answer. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "why is += in a loop slow, if mine is fast". "The documentation only promises that concatenation gives a new object, and calls repeated concatenation quadratic. CPython can extend a string in place, but the conditions are an implementation detail: in the measurement on 3.13 the fast path was switched off by an extra reference, a computed hash, a str subclass, a non-ASCII character and even s += x; k += 1 on one line. Hence join — and on your own machine and version this is worth measuring, not guessing."

On "how do you reverse a string". "s[::-1] — a slice with step −1, it returns the reversed string. It rearranges code points, so text with combining marks or composite emoji cannot be reversed this way."

On "how does find differ from index". "Not found — find returns −1, index raises ValueError. And −1 is truthy, so if s.find(x): is a bug."

On "what will f'{2.5:.0f}' print". "2: a number exactly halfway is rounded to even, like round(2.5)."

Next they ask

Next they ask

Why is an f-string better than str.format and %?

Short answer

Readability: the expression stands right where its value goes into the text. The specification after the colon is the same for an f-string and str.format; % has its own, narrower syntax. In speed, the f-string beat str.format and % on both versions in the measurement. Below is the 3.13.7 output; on 3.14.7 concatenation through str() was faster than the f-string — 131.6 versus 152.2 ns:

2) one formatting: a name and a number, ns, medians of 7 rounds
   f-string         167.1
   str.format       355.9
   % operator       271.5
   + and str()      190.5
Next they ask

Why is the length of a six-letter Cyrillic word 6, while a file with it takes 12 bytes?

Short answer

len of a string counts code points. A file stores bytes in an encoding — if the file is UTF-8, a Cyrillic letter takes two bytes. In the run the six-letter Russian word is six code points and twelve bytes, and '👍' is one code point and four bytes.

Next they ask

How do you change one character in a string?

Short answer

You cannot — only build a new string: s[:i] + 'X' + s[i + 1:]. If many characters change, it is more convenient to turn the string into a list of characters, change it and assemble it back with ''.join(chars).

Common misconceptions

Claim

s += '!' changes the string in place

Actually

It binds the name to a new string. After t = s; s += '!' the name t sees the old value, and s is t is False.

Claim

A slice beyond the string raises IndexError

Actually

Slice bounds are clipped to the string's length: p[10:20] gives an empty string, p[:100] the whole string. Only an index raises IndexError: p[10].

Claim

f'{2.5:.0f}' prints 3

Actually

It prints 2: a value exactly halfway is rounded to even. round(2.5) is 2 too, while round(3.5) is 4.

Claim

Concatenation with += in a loop is always quadratic

Actually

The documentation calls it quadratic, but under a number of conditions CPython extends the string in place — then, at the sizes measured, the time per piece did not grow. The conditions are a detail of the version: an extra reference gave ×13.6 per piece for ×10 pieces, and on 3.13 and 3.14 even writing s += x; k += 1 on one line brought the quadratic cost back. The reliable way is join.

Claim

The len of a string is its size in bytes or the number of visible letters

Actually

It is the length in code points. A six-letter Cyrillic word is 6 code points and 12 bytes in UTF-8, and the letter é written as 'e\u0301' is 2 code points.

Version history

VersionChangeWhat it means for code
3.8f-strings got the debug specifier: f'{x=}' prints the name and the value.
3.11The reference point of the lesson. Inside an f-string you cannot use quotes of the same kind as outside: f"{d["k"]}" is SyntaxError: f-string: unmatched '['.
3.12Some restrictions were lifted from expressions in f-strings — nested quotes of the same kind, comments and backslashes are allowed. Code with such f-strings does not compile on 3.11.
3.13An observation of the run, not a contract: s += x; k += 1 on one line switches off the concatenation fast path — ×32.3 per piece for ×10 pieces; on 3.11 and 3.12 the same code is linear. On 3.14.7 it is the same as on 3.13.
3.14t-strings appeared: t"…" gives a Template object, not a string. On 3.13 and earlier it is a SyntaxError.
3.15Released on 9 October 2026; the lesson was not run on it. According to the release notes, "Python now uses UTF-8 as the default encoding, independent of the system's environment"; the previous behavior is PYTHONUTF8=0 or -X utf8=0. An explicit encoding="utf-8" is still needed by code that also runs on versions before 3.15.

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

A string was copied into another name, then appended to. What will this code print?
s = "ab"
t = s
s += "c"
print(s, t, s is t)

Practice · estimate

How many bytes does the Russian phrase for 'hello, world' (11 characters: 9 Cyrillic letters, a comma and a space) take in UTF-8?
bytes

Check yourself

Question 1 of 4

What does 'Python'[10:20] give?

How it was measured

The numbers and observations of this lesson come from these scripts. Each one opens right from here — together with the record of its run: what it ran on, what came out and with what spread.

Runs: CPython 3.11.15, 3.12.3, 3.13.7 and 3.14.7, Linux x86_64, one machine (2 cores); quotes from the documentation and sources were checked on 14.10.2026. Python 3.15 was out by that date, but it is not in the measurement environment.

Sources & further reading

6 SOURCES

  1. The data model — immutable sequencesOfficial documentation. What immutability means: «An object of an immutable sequence type cannot change once it is created». Strings come first in the list of such types. The text was checked against the built-in pydoc help (the pydoc_data.topics module) of CPython 3.13.7 and 3.14.7.https://docs.python.org/3.13/reference/datamodel.html#immutable-sequences
  2. Built-in types — common sequence operations, note 6Official documentation. Why concatenation in a loop is expensive: «Concatenating immutable sequences always results in a new object. This means that building up a sequence by repeated concatenation will have a quadratic runtime cost in the total sequence length». And the advice: «if concatenating str objects, you can build a list and use str.join() at the end or else write to an io.StringIO instance and retrieve its value when complete». Checked against pydoc_data.topics of 3.13.7 and 3.14.7.https://docs.python.org/3.13/library/stdtypes.html#common-sequence-operations
  3. The language reference — f-strings and t-stringsOfficial documentation. The history of f-strings: «Changed in version 3.8: Added the debug specifier ("=") Changed in version 3.12: Many restrictions on expressions within f-strings have been removed. Notably, nested strings, comments, and backslashes are now permitted». On t-strings: «Added in version 3.14. A template string literal or t-string is a string literal that is prefixed with ‘t’ or ‘T’». Checked against pydoc_data.topics of 3.14.7; the built-in help of 3.13.7 has no section on f-strings.https://docs.python.org/3.14/reference/lexical_analysis.html#f-strings
  4. The format specification mini-languageOfficial documentation. How formatting a number rounds: «The rounding mode for float matches that of the round() builtin». Checked against pydoc_data.topics of 3.13.7 and 3.14.7.https://docs.python.org/3.13/library/string.html#format-specification-mini-language
  5. Python/bytecodes.c — fast concatenation BINARY_OP_INPLACE_ADD_UNICODECPython source code. Lines 544–553 at tag v3.13.7 (the same comment in lines 771–780 at v3.14.0). Why the fast path exists: «When possible, extend left in place rather than allocating a new PyUnicodeObject. This attempts to avoid quadratic behavior when one neglects to use str.join()». And the condition: «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».https://github.com/python/cpython/blob/v3.13.7/Python/bytecodes.c
  6. What's new in Python 3.15 — UTF-8 by defaultOfficial documentation. Python 3.15 was released on 9 October 2026. The release notes: «Python now uses UTF-8 as the default encoding, independent of the system's environment»; PYTHONUTF8=0 or -X utf8=0 restores the previous behavior. The quote was checked on 14.10.2026. The lesson was not run on 3.15: the measurement environment does not have that version.https://docs.python.org/3.15/whatsnew/3.15.html