Strings are immutable sequences of characters. Visualize indexing, slicing, and transformations in real-time.
Overview
Overview
Strings are one of the most important and commonly used data types in Python. They are sequences of characters, used to represent text. This interactive lab is designed to help you visualize how strings work, particularly their immutable nature and the power of their built-in methods for transformation and slicing.
Operations
Snippet
String State
s = "..."
len: 8
Modify the base string or apply methods to see changes.
Logic Insight
Python strings are sequences of Unicode characters.
•Immutability: You cannot change a character in place. `s[0] = 'x'` is an error.
•Methods: String methods always return a new string object.
•Indexing: Negative indices count from the end (e.g., -1 is the last char).
Memory State
Immutability CheckPassed
Strings in Python are pre-allocated and cached when short.
Understanding Python Strings
Strings are one of the most important and commonly used data types in Python. They are sequences of characters, used to represent text. This interactive lab is designed to help you visualize how strings work, particularly their immutable nature and the power of their built-in methods for transformation and slicing.
Quick Context: The Immutable Sequence
The most critical concept to understand about Python strings is that they are immutable. This means that once a string is created, it cannot be changed. Every time you see a string method that appears to "modify" a string (like .upper() or .replace()), it is actually creating and returning a new string with the changes. The original string remains untouched.
Sequence Type: Strings are sequences, which means their characters are ordered. You can access individual characters using an index.
Zero-Indexed: The first character is at index 0, the second at index 1, and so on.
Immutable: You cannot change a character in a string, e.g., my_string[0] = 'H' will raise a TypeError. This is a key difference from lists.
This lab visualizes this immutability. When you apply a transformation, you'll see the original string at the top and the new, transformed string at the bottom, reinforcing the idea that a new object has been created.
Core Idea: Indexing, Slicing, and Methods
Working with strings involves three main techniques, all of which you can explore in this lab:
Indexing
Access a single character using its position in square brackets, like my_string[0]. Python also supports negative indexing, where my_string[-1] accesses the last character.
Slicing
Extract a substring (a "slice") using the syntax [start:stop:step]. This is a powerful way to get parts of a string without altering the original.
Methods
Strings come with a rich library of methods to perform common text operations, such as changing case (.lower()), finding substrings (.find()), or replacing parts of a string (.replace()).
Immutable, and why that shapes everything
A Python string cannot be changed. Every operation that looks like a modification creates a new string.
s = "hello"
s.upper() # 'HELLO'
print(s) # 'hello' - unchanged
s = s.upper() # to keep the result, rebind the name
That immutability buys real things: strings can be dictionary keys, they can be shared between parts of a program without defensive copying, and the interpreter can cache and intern them.
It also has one significant performance consequence:
# O(n^2) - each += copies the entire string built so far
result = ""
for word in words:
result += word
# O(n) - collect the pieces, join once
result = "".join(words)
At 10,000 words the first version does about 50 million character copies; the second does 10,000 appends and one allocation. "".join() is the idiomatic way to build a string, and the separator is the string you call it on.
Slicing, and the one rule
Strings slice exactly like lists, and the stop index is always excluded.
s = "Hello, World"
s[0] # 'H'
s[-1] # 'd'
s[0:5] # 'Hello'
s[7:] # 'World'
s[:5] # 'Hello'
s[::2] # 'Hlo ol' - every second character
s[::-1] # 'dlroW ,olleH' - reversed
len(s) # 12
Out-of-range slices return what exists rather than raising, so s[5:100] is safe while s[100] is an IndexError.
Slicing copies, so s[:] produces an equal string and slicing a very long string repeatedly in a loop allocates repeatedly. For scanning without copying, iterate or use indices.
The methods that matter
text = " Hello, World "
text.strip() # 'Hello, World' - also lstrip, rstrip
text.lower() / .upper() # case conversion
text.replace("World", "Ada") # a new string
text.split(",") # [' Hello', ' World ']
text.strip().startswith("H") # True - also endswith
"World" in text # True - substring test
text.find("World") # index, or -1 if absent
text.count("l") # 3
", ".join(["a", "b"]) # 'a, b'
Two habits prevent a large share of real string bugs.
Always strip input. Data from files, forms and APIs carries trailing whitespace and newlines, and "yes\n" == "yes" is False. This is the most common cause of "but they look identical".
Normalise case before comparing.a.lower() == b.lower(), or a.casefold() == b.casefold() for correct handling of non-English text — casefold is more aggressive and handles cases like the German ß.
Note also split() with no argument: it splits on any run of whitespace and discards empty results, which is almost always what you want for parsing text. split(" ") splits on each single space and can produce empty strings.
Immutability, and the loop that quietly copies everything
Python strings cannot be modified, so every operation that looks like a modification is building a new string. That single fact explains the performance trap in string building, the reason slicing is safe to hand around, and why the join idiom exists at all.
example_01.pyPython
import time
# Nothing mutates a string. Every method returns a new one:
s = "hello"
print("s.upper() ->", s.upper(), " s is still", s)
try:
s[0] = "H"
except TypeError as e:
print("s[0] = 'H' ->", e)
# So building a string in a loop with += creates a new string every
# iteration, copying everything accumulated so far.
def by_concat(n):
out = ""
for i in range(n):
out += "x"
return out
def by_concat_aliased(n):
out = ""
for i in range(n):
keep = out # a second reference to the string
out += "x"
return out
def by_join(n):
parts = []
for i in range(n):
parts.append("x")
return "".join(parts)
def ms(f, n):
t0 = time.time(); f(n); return (time.time() - t0) * 1000
print()
print("%8s %12s %18s %12s" % ("n", "+= (ms)", "+= aliased (ms)", "join (ms)"))
for n in (10000, 40000, 160000):
print("%8d %12.1f %18.1f %12.1f" % (
n, ms(by_concat, n), ms(by_concat_aliased, n), ms(by_join, n)))
# The first column is the surprise: += looks perfectly fine, close to
# join. That is not because concatenation is cheap -- it is because
# CPython has an optimisation that resizes the string IN PLACE when it can
# prove nothing else refers to it.
#
# The middle column is the same loop with one extra assignment, which
# creates a second reference and takes the optimisation away. Now the real
# cost shows: quadruple n and the time goes up by roughly an order of
# magnitude, heading for the sixteenfold that O(n^2) predicts, because
# each += copies everything accumulated so far.
#
# So += on strings is fast until it silently is not, and what disables it
# is as innocuous as keeping the previous value, passing it to a function
# or storing it in a list. join() does not depend on any of that: it builds
# a list of pieces with cheap appends, then allocates the final string
# exactly once, knowing the total length in advance.
#
# SLICING. A slice is a new string, so it can never alias the original:
text = "abcdefghij"
print()
print(" text[2:5] ", text[2:5])
print(" text[:3] ", text[:3])
print(" text[-3:] ", text[-3:])
print(" text[::2] ", text[::2])
print(" text[::-1] ", text[::-1])
print(" text[5:2] ", repr(text[5:2]), "-- backwards slice, empty not error")
# The one rule that makes all of them predictable: start is included, stop
# is excluded, and out-of-range bounds are clamped rather than raised:
print()
print(" text[3:999] ", text[3:999], " -- no IndexError")
try:
text[999]
except IndexError as e:
print(" text[999] IndexError:", e)
# Indexing raises and slicing does not, which is a common source of a bug
# that returns "" instead of failing.
#
# And the methods worth knowing, all of them returning new strings:
line = " Name , Ashish , Delhi \n"
print()
print(" raw ", repr(line))
print(" .strip() ", repr(line.strip()))
print(" .split(',') ", [p.strip() for p in line.split(",")])
print(" .replace ", repr(line.strip().replace(" ,", ",")))
print(" 'x' in line ", "Ashish" in line)
# split() then strip() on each part is the idiom for parsing this kind of
# line, and it is worth preferring over a regular expression for anything
# this simple -- it is faster to read and it fails in obvious ways.
Output
Experiments to try
Try these experiments to solidify your understanding of string manipulation.
Case Transformations: Enter a mixed-case string like "ViZlEaRn". Apply the .upper(), .lower(), .capitalize(), and .title() methods. Observe the distinct output of each and how they produce a completely new string.
The Power of Slicing: Select the "Slicing" method with the string "Python".
Set "Start" to 2 and "Stop" to 4. The result is "th". Notice the character at the "Stop" index is not included.
Leave "Start" and "Stop" blank, but set "Step" to 2. This gives you every second character: "Pto".
Set "Step" to -1. This is a classic Python idiom for reversing a string!
Understanding .replace(): Use the string "hello world". Select the .replace() method. Replace "l" with "x". Notice that all occurrences of "l" are replaced. This method is case-sensitive; replacing "H" will not affect "h".
Summing up
Immutability is Law: Always remember that string methods do not change the original string. They return a new one. Forgetting this is a common bug, e.g., writing my_string.upper() without assigning the result back to a variable.
Slicing is Non-Destructive: Slicing is a safe way to get parts of a string. It always produces a new string and never modifies the original.
Rich Method Library: Python's string methods are powerful and efficient. Before writing your own function to manipulate a string, always check if a built-in method already does what you need.
Strings are Iterable: You can loop over a string directly, e.g., for char in my_string: print(char), which is useful for character-by-character processing.
f-strings
f-strings take expressions, not just names, and their formatting mini-language covers most output needs:
The = suffix prints both the expression and its value, which removes most of the reason to write print("name:", name).
Multi-line strings use triple quotes and preserve line breaks, which suits SQL, templates and docstrings. textwrap.dedent removes the common leading whitespace when the string is indented in source.
Unicode, and the trap that catches everyone
Python 3 strings are sequences of Unicode code points. Files and networks carry bytes, so text is encoded on the way out and decoded on the way in:
data = "café".encode("utf-8") # b'caf\xc3\xa9' - 5 bytes
text = data.decode("utf-8") # 'café' - 4 characters
Three consequences worth knowing:
Characters and bytes differ.len("café") is 4; its UTF-8 encoding is 5 bytes. Truncating at a byte boundary can split a character.
Always specify the encoding when opening a text file. The default depends on the operating system, which is why a script that works on one machine raises UnicodeDecodeError on another.
The same text can have two encodings. "é" may be one code point or an "e" plus a combining accent. They look identical and compare as different strings:
import unicodedata
a = unicodedata.normalize("NFKC", a)
b = unicodedata.normalize("NFKC", b)
a == b # now comparable
That normalisation should be the first step of any pipeline handling text from the outside world. Skipping it produces the classic "these two records should match and do not" bug.
Performance notes
Interning. Short string literals are cached, so "a" is "a" may be True. Never rely on it — use == for equality, always.
in is fast. Python's substring search is a tuned C implementation (a Boyer-Moore-Horspool hybrid), so "needle" in haystack will beat a hand-written search comfortably.
Regular expressions are slower than string methods.startswith, in and split are faster than the equivalent regex, so reach for regex only when the pattern genuinely needs it. Compile the pattern once with re.compile if it is used repeatedly.
str.translate is the fastest way to remove or map many characters at once, faster than chained replace calls.
Questions people ask
How do I reverse a string?text[::-1].
Why is += in a loop slow? Strings are immutable, so each concatenation copies everything so far. Use "".join(parts).
What is the difference between str and bytes?str is Unicode text; bytes is raw 8-bit data. Encode to go from one to the other, and Python 3 will not mix them implicitly.
Is str.format() obsolete? Not obsolete — f-strings are clearer for almost every case, and format is still right when the template comes from a config file or a translation catalogue.
How do I pad a number?f"{n:03d}" gives '007'.
Why does my comparison fail on identical-looking strings? Unicode normalisation, a curly versus straight apostrophe, or trailing whitespace. Check with repr().
Recap in one screen
Strings are immutable; every "modifying" method returns a new one.
Build strings with "".join(parts), never with += in a loop.
Slicing excludes the stop index and never raises on out-of-range bounds.
Strip and case-normalise anything from a human or a file before comparing it.
Text is Unicode and files are bytes — state the encoding, and normalise with NFKC before comparing.
Run it in Python
Strings are immutable, and nearly everything surprising about them follows from that one fact. The middle block times the loop that every beginner writes against the idiom that replaces it.
strings.pyPython 3
# Strings are immutable. Every "change" builds a new string.
import time
s = "algorithms"
print("s :", s)
print("s[0], s[-1]:", s[0], s[-1])
print("s[2:6] :", s[2:6], "- a new string, not a view")
print("s[::-1] :", s[::-1], "- the standard reversal idiom")
try:
s[0] = "A"
except TypeError as e:
print("s[0] = 'A' ->", e)
print("s.replace('a', 'A') ->", s.replace("a", "A"), " original still:", s)
# --- the loop everyone writes first ------------------------------------
class Accumulator:
def __init__(self):
self.text = ""
print()
print(f"{'n':>7} {'+= in a loop':>14} {'list + join':>13} {'ratio':>7}")
for n in (20_000, 40_000, 80_000):
acc = Accumulator()
start = time.time()
for _ in range(n):
acc.text += "x" # a new string each time: O(n) per step
concat = time.time() - start
start = time.time()
parts = []
for _ in range(n):
parts.append("x") # O(1) each...
text = "".join(parts) # ...then one allocation: O(n) overall
joined = time.time() - start
print(f"{n:>7} {concat:>13.4f}s {joined:>12.4f}s {concat / joined:>6.1f}x")
print("Double n: the join column doubles. The += column quadruples.")
# --- the methods that do the work --------------------------------------
line = " Name , Age , City "
print()
print("split+strip:", [f.strip() for f in line.strip().split(",")])
print("startswith :", "algorithms".startswith("algo"))
print("find :", "algorithms".find("rit"), "- index, or -1")
print("join :", "-".join(["a", "b", "c"]))
# --- characters are strings too ----------------------------------------
print()
word = "level"
print(f"{word!r} is a palindrome:", word == word[::-1])
print("counts:", {ch: word.count(ch) for ch in sorted(set(word))})
print("ord/chr:", ord("a"), chr(98), "- the numbers behind the characters")
Output
How the code works
s[0] = "A" -> TypeErrorImmutability is enforced, not advisory. It is also what lets strings be hashable, and therefore usable as dictionary keys — a mutable string could not be.
acc.text += "x"Each += allocates a new string and copies everything so far, so building n characters costs O(n²). The table measures growth rather than one timing, because that is the part that does not depend on the machine.
why an attribute, not a local variableCPython has a special case that can resize a string in place when the target is a plain local and nothing else refers to it, which makes the textbook example look fine on some builds and not others. Storing into an attribute takes that variable out of the measurement — and a rescue that depends on the interpreter build is not one to write code against.
"".join(parts)One pass to compute the total length, one allocation, one copy. It is the idiomatic answer, it is the fast one, and it does not depend on an interpreter detail to stay fast.
s[2:6]Slicing copies. On a large string in a loop that is a real cost, and it is why algorithms that scan text carry indices around instead of slicing as they go.
word == word[::-1]Readable, and it allocates a full reversed copy. The two-pointer version uses O(1) memory — worth knowing which one you are writing.
Change one thing
Swap acc.text for a plain local variable out and re-run. Whether the quadratic disappears depends on the interpreter you are running — which is the argument for join in one line.
Check whether two words are anagrams with sorted(a) == sorted(b), then again with Counter. O(n log n) against O(n).
Try "café"[::-1] and len("café"). Python 3 strings are sequences of code points, so this behaves — and an emoji with a skin-tone modifier still will not.
Where this runs
Real CPython, compiled to WebAssembly and running on your own machine — nothing is uploaded. The first run takes a few seconds while the interpreter downloads; after that it is immediate. Need more room, or want to paste your own attempt? Use the Python compiler.
Check yourself
0 of 3
Answer without scrolling back up.
Building a string by += in a loop is O(n²) because each step:
Strings are immutable, so there is nothing to append to. "".join(parts) does one length calculation, one allocation and one copy.
The program accumulates into an object attribute rather than a local variable. Why?
The optimisation only fires under specific conditions and varies by build - which is itself the argument for using join rather than relying on it.
Immutability is also what allows strings to be:
Hashability requires that the value cannot change underneath the table. A mutable string could not be a key.
Cheat sheet
Python String Lab
Strings are one of the most important and commonly used data types in Python. They are sequences of characters, used to represent text. This interactive lab is designed to help you visualize how strings work, particularly their immutable nature and the power of their built-in methods for transformation and slicing.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.