A string in Python is a sequence of characters, written between quotes, and it cannot be changed after it’s created.
name = "Hello"
name[0] = "J"
# TypeError: 'str' object does not support item assignmentThat immutability is the fact everything else follows from. Every method that appears to modify a string — upper(), replace(), strip() — actually returns a new one and leaves the original alone.
This guide covers how to create strings, the three ways to format them, slicing, the methods you’ll use daily, and the encoding difference that breaks database columns.
| Created with | Single, double or triple quotes — all equivalent |
| Immutable | Every method returns a new string |
| Best formatting | f-strings, since Python 3.6 |
| Types in Python 3 | Two: str and bytes — unicode no longer exists |
| Compare with | ==, never is |
Table of Contents
Creating a string
Quotes — single or double, it makes no difference to Python:
name = "Ana"
city = 'Lisbon'print(type(name)) # <class 'str'>The choice matters only when the text contains a quote:
message = "It's fine" # double outside, single inside
quote = 'She said "hello"' # single outside, double insideAnd triple quotes hold several lines:
text = """First line
Second line"""print(repr(text)) # 'First line\nSecond line'⚠️ The line breaks are real characters — the string contains \n where you pressed Enter.
⚠️ Strings are immutable

This is the single most important thing about them, and the source of most confusion.
word = "Hello"word.upper()
print(word) # 'Hello' ← unchangedword = word.upper()
print(word) # 'HELLO' ← now it's the new oneupper() didn’t modify anything. It built a new string and returned it — and the first call threw that new string away, because nothing captured it.
The same is true of every string method: lower(), strip(), replace(), title(), split(). None of them changes the original.
⚠️ This is the opposite of list methods. my_list.sort() modifies the list and returns None; my_string.upper() leaves the string alone and returns a new one. Our guide on methods that return None covers the list side.
And it’s why building a string in a loop is inefficient:
result = ""
for word in words:
result += word # a new string on every iterationresult = "".join(words) # one allocation — the right wayFormatting: three ways, one recommendation
f-strings — use these
Since Python 3.6. An f before the opening quote, and expressions in braces:
name = "Ana"
age = 30print(f"{name} is {age} years old")
# Ana is 30 years old⚠️ Braces, not brackets — {}, and the f goes outside the quote, not inside.
They handle formatting too:
print(f"{3.14159:.2f}") # 3.14 ← two decimals
print(f"{1234567:,}") # 1,234,567 ← thousands separator
print(f"{0.856:.1%}") # 85.6% ← percentage
print(f"{42:>8}") # ' 42' ← right-aligned in 8 charsAnd any expression works inside the braces — f"{price * 1.2:.2f}", f"{name.upper()}", even a conditional.
.format() — the previous standard
print("{} is {} years old".format("Ana", 30))
print("{1} before {0}".format("a", "b")) # b before aStill valid, and useful when the template comes from elsewhere — a config file, a translation table — since you can’t put an f-string in data.
⚠️ Extra arguments are silently ignored. "{}".format(1, 2) prints just 1 — no error, no warning.
% — legacy
print("%s is %d years old" % ("Ana", 30))Inherited from C. It still works, and you’ll see it in older code, but there’s no reason to choose it for new work.
Concatenation with +
greeting = "Hello" + " " + "World" # 'Hello World'"Age: " + 30
# TypeError: can only concatenate str (not "int") to str⚠️ + only joins strings. A number needs str(30) — which is one of the reasons f-strings are better, since they convert automatically.
Slicing: getting part of a string
text = "Hello World"text[0] # 'H' ← first character
text[-1] # 'd' ← last character
text[0:5] # 'Hello' ← from 0 up to (not including) 5
text[:5] # 'Hello' ← same, start omitted
text[6:] # 'World' ← from 6 to the end
text[::-1] # 'dlroW olleH' ← reversedThe syntax is [start:end:step], and the end index is exclusive — [0:5] gives five characters, positions 0 through 4.
⚠️ The syntax is brackets on the string, not a slice() call. Writing slice(0:5) is a SyntaxError.
And a slice that runs off the end doesn’t raise:
"Hello"[0:100] # 'Hello' ← no error
"Hello"[100] # IndexError: string index out of rangeSlicing is forgiving; indexing isn’t.
The methods you’ll use daily
| Method | What it returns |
|---|---|
upper() / lower() | A new string, all caps or all lowercase |
strip() | Without whitespace at the ends |
replace(a, b) | With every occurrence replaced |
split(sep) | A list of substrings |
sep.join(list) | One string from a list — the inverse of split |
find(x) | The position, or -1 if absent |
startswith / endswith | True or False |
Three details worth knowing
replace() is case-sensitive:
"Hello World".replace("hello", "Hi") # 'Hello World' ← nothing changedIt found no lowercase “hello”, so it replaced nothing — and raised no error.
split() keeps the spaces around the separator:
"Ana, Joao".split(",") # ['Ana', ' Joao']
[x.strip() for x in "Ana, Joao".split(",")] # ['Ana', 'Joao']Our guide on split() covers the rest.
And join() only accepts strings:
",".join([1, 2])
# TypeError: sequence item 0: expected str instance, int found",".join(str(n) for n in [1, 2]) # '1,2'Searching
"World" in "Hello World" # True ← simplest check"Hello".find("z") # -1 ← not found, no error
"Hello".index("z") # ValueError: substring not foundin for a yes-or-no question, find() when you need the position — and index() only if you want the exception.
Comparing strings
"Hello" == "Hello" # True
"abc" == "cba" # False ← order matters
"Hello" == "hello" # False ← case mattersUse ==, never is. ⚠️ is compares object identity, and whether two equal strings are the same object depends on internal optimisations you can’t rely on — Python even warns about it.
For case-insensitive comparison:
"Hello".lower() == "hello".lower() # True
"Hello".casefold() == "hello".casefold() # True ← more thoroughcasefold() handles cases lower() misses in languages like German, where ß should match ss. For comparing user input, prefer it.
str and bytes: two types, not three
Python 3 has exactly two string types. The unicode type existed in Python 2 and no longer exists:
unicode("text")
# NameError: name 'unicode' is not definedIn Python 3, str is Unicode — that was the point of the change. Every string holds text from any writing system by default.
bytes is the other type, for binary data:
text = "café"
data = text.encode("utf-8")print(data) # b'caf\xc3\xa9'
print(data.decode("utf-8")) # 'café'⚠️ The difference that breaks database columns
len("café") # 4 ← characters
len("café".encode("utf-8")) # 5 ← bytes, because é takes twoA column declared as 50 bytes holds fewer than 50 accented characters — and the truncation only appears with real data, never with test values in English.
Our guide on len() covers the other cases where character counting surprises.
Where strings bite on a server
Two failures that only appear in production.
Encoding on file reads. A CSV exported from Excel on Windows arrives in Latin-1, and reading it as UTF-8 raises:
open("data.csv", encoding="utf-8")
# UnicodeDecodeError: 'utf-8' codec can't decode byte...open("data.csv", encoding="latin-1") # works⚠️ Always specify the encoding explicitly. Without it, Python uses the system default — which differs between your laptop and the server, producing a script that works locally and fails in production.
And string building in a loop. Concatenating with += creates a new string each time, so a loop over a large file is quadratic. "".join(parts) is one allocation — on a server processing real volume, the difference is minutes rather than milliseconds.
Python uses the system’s default encoding when you don’t specify one — and that default differs between your laptop and the host. Copahost VPS plans give you root access, a locale you control, and your choice of Python version. From €3.99/month, with snapshots.
See VPS plansFrequently asked questions
Are strings mutable in Python?
No — strings are immutable. text[0] = "x" raises TypeError. Every method that seems to modify one, like upper() or replace(), actually returns a new string and leaves the original untouched.
Why didn’t upper() change my string?
Because it returned a new string that you didn’t keep. Calling text.upper() alone does nothing visible — you need text = text.upper(). This applies to every string method.
Single or double quotes — does it matter?
Not to Python; both create the same type. Choose based on the content: use double quotes for text containing an apostrophe, single quotes for text containing double quotes. Triple quotes hold multiple lines.
What’s the best way to format strings?
f-strings, available since Python 3.6. They’re shorter, faster, and convert types automatically — f"Age: {age}" works where "Age: " + age raises TypeError. ⚠️ Use .format() only when the template comes from data, since you can’t put an f-string in a config file.
How many string types does Python 3 have?
Two: str and bytes. The unicode type belonged to Python 2 and no longer exists — in Python 3, str is Unicode by default. bytes holds binary data, and you move between them with encode() and decode().
Why is len() of my string different from the byte count?
Because len() counts characters and encoding counts bytes. len("café") is 4, but len("café".encode("utf-8")) is 5 — the accented letter takes two bytes. ⚠️ This is why a 50-byte database column holds fewer than 50 accented characters.
Why didn’t replace() replace anything?
Because it’s case-sensitive, and it raises no error when it finds nothing. "Hello".replace("hello", "Hi") returns "Hello" unchanged. To match regardless of case, lowercase both sides first — or use re.sub() with the ignore-case flag.
Should I compare strings with == or is?
Always ==. The is operator compares object identity, and whether two equal strings are the same object depends on internal optimisations you can’t rely on. Python even emits a warning when you use is with a string literal.
How do I compare strings ignoring case?
Lowercase both sides: a.lower() == b.lower(). For user input, prefer casefold() — it handles cases lower() misses, such as German ß matching ss.
Why is concatenating in a loop slow?
Because strings are immutable, so result += word creates a new string on every iteration, copying everything that came before. ⚠️ On large volumes it’s quadratic. Collect the pieces in a list and call "".join(parts) once.
How do I reverse a string?
With a slice: text[::-1]. There’s no reverse() method on strings — reversed() works but returns an iterator, so you’d need "".join(reversed(text)).
Why does my script fail to read a file on the server but works locally?
Almost always the encoding. Python uses the system default when you don’t specify one, and that default differs between machines. ⚠️ Always pass encoding= explicitly — "utf-8" for most files, "latin-1" for CSVs exported from Excel on Windows.
Conclusion
A string in Python is a sequence of characters between quotes — and it cannot be changed after it’s created. Every method returns a new one.
Three things follow from that. text.upper() alone does nothing — you have to assign the result. Building a string in a loop with += is quadratic, because each step copies everything before it; "".join(parts) is one allocation. And f-strings are the right default for formatting, since they convert types automatically and read better than the alternatives.
And the one that only appears with real data: len() counts characters, encoding counts bytes. len("café") is 4 and its UTF-8 encoding is 5 — which is why a column sized in bytes truncates accented text that looked short enough in testing.
