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.
- 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".
- string immutability, slices,
split,join,findversusindex; - format specifications, the debug
=, rounding to even; strandbytes, 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.
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:
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 +=
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 touchs; 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 butsrefers 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
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, whilep[10]gives anIndexError. 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
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(',')andsplit()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.joinis a method of the separator, and accepts only strings.'-'.join([1, 2])fails; numbers are first turned into strings:'-'.join(map(str, nums)).findversusindex. Not found —findreturns −1,indexraisesValueError. The checkif s.find(x):is a bug: −1 is truthy, and 0 is falsy.isdigit()does not check "this is a number". For'-42'it isFalse. It is more reliable to tryint(s)and catchValueError.
Mechanism 4: formatting
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
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
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.
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
Why is an f-string better than str.format and %?
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
Why is the length of a six-letter Cyrillic word 6, while a file with it takes 12 bytes?
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.
How do you change one character in a string?
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
s += '!' changes the string in place
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.
A slice beyond the string raises IndexError
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].
f'{2.5:.0f}' prints 3
It prints 2: a value exactly halfway is rounded to even. round(2.5) is 2 too, while round(3.5) is 4.
Concatenation with += in a loop is always quadratic
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.
The len of a string is its size in bytes or the number of visible letters
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
| Version | Change | What it means for code |
|---|---|---|
| 3.8 | f-strings got the debug specifier: f'{x=}' prints the name and the value. | |
| 3.11 | The 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.12 | Some 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.13 | An 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.14 | t-strings appeared: t"…" gives a Template object, not a string. On 3.13 and earlier it is a SyntaxError. | |
| 3.15 | Released 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
s = "ab" t = s s += "c" print(s, t, s is t)
Practice · estimate
Check yourself
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.
bench/strfmt/example.pybench/strfmt/basics.pybench/strfmt/cost.pybench/strfmt/fastpath.pybench/strfmt/practice.py
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.
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.
The gist
- 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 aTypeError. Methods —upper,replace,strip— return a result, and the original string does not change. Ands += '!'bindssto a new value: a namettaken 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 thanjoin), a computed hash, astrsubclass, 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}'prints2: 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.
In fact
- It binds the name to a new string. After
t = s; s += '!'the nametsees the old value, ands is tisFalse. - Slice bounds are clipped to the string's length:
p[10:20]gives an empty string,p[:100]the whole string. Only an index raisesIndexError:p[10]. - It prints
2: a value exactly halfway is rounded to even.round(2.5)is2too, whileround(3.5)is4. - 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 += 1on one line brought the quadratic cost back. The reliable way isjoin. - 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.
By version
- 3.8
- f-strings got the debug specifier:
f'{x=}'prints the name and the value.< - 3.11
- The reference point of the lesson. Inside an f-string you cannot use quotes of the same kind as outside:
f"{d["k"]}"isSyntaxError: f-string: unmatched '['.< - 3.12
- Some 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.13
- An observation of the run, not a contract:
s += x; k += 1on 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.14
- t-strings appeared:
t"…"gives aTemplateobject, not a string. On 3.13 and earlier it is aSyntaxError.< - 3.15
- Released 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=0or-X utf8=0. An explicitencoding="utf-8"is still needed by code that also runs on versions before 3.15.<
What is covered
- Base: a string is an immutable sequence
- Mechanism 1: immutability and +=
- Mechanism 2: slices and indices
- Mechanism 3: the methods people ask about
- Mechanism 4: formatting
- Mechanism 5: str and bytes
- Deeper: the cost of concatenation and CPython's fast path
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Check yourself
- How it was measured
Sources & further reading
6 SOURCES
- 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
- 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
- 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
- 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
- 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
leftin place rather than allocating a new PyUnicodeObject. This attempts to avoid quadratic behavior when one neglects to use str.join()». And the condition: «Iflefthas only two references remaining (one from the stack, one in the locals), DECREFingleftleaves 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 - 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