Strings in Python: Creating, Formatting and Manipulating

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 assignment

That 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.

The short version
Created withSingle, double or triple quotes — all equivalent
ImmutableEvery method returns a new string
Best formattingf-strings, since Python 3.6
Types in Python 3Two: str and bytes — unicode no longer exists
Compare with==, never is

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 inside

And 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

Strings in Python are immutable: calling upper without assigning discards the result, while assigning it points the variable to the new string — the original never changes

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 one

upper() 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 way

Formatting: 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 chars

And 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 a

Still 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' ← reversed

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

Slicing is forgiving; indexing isn’t.

The methods you’ll use daily

MethodWhat 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 / endswithTrue or False

Three details worth knowing

replace() is case-sensitive:

"Hello World".replace("hello", "Hi") # 'Hello World' ← nothing changed

It 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 found

in 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 matters

Use ==, 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 thorough

casefold() 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 defined

In 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 two

A 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.

The script works locally and fails on the server

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 plans

Frequently 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.

Share the Post:
Picture of Schenia T

Schenia T

Data scientist, passionate about technology tools and games. Undergraduate student in Statistics at UFPB. Her hobby is binge-watching series, enjoying good music working or cooking, going to the movies and learning new things!