Skip to content

Strings

docs.scrimba.com

Text shows up in almost every program you write. Names, messages, scores, labels. In Python, any piece of text is called a string: any value you wrap in quote marks. Single or double, both work the same way.

Strings are Python's primary text type. They carry everything from a username to a URL path to formatted output. Single and double quotes produce identical results; the choice is stylistic.

str sits at every system boundary: terminal I/O, file contents, network responses, serialised data. It is Python's immutable (unchangeable after creation) Unicode sequence type. Both quote styles produce the same object, so the choice between them is purely stylistic.

python
greeting = "Hello, world"
username = 'alice'

The only time the choice of quotes matters is when your text contains quote marks. Use the opposite style so you don't have to escape them:

The community convention is double quotes. The practical reason to switch styles is to avoid escaping when the content contains that character:

Convention is double quotes, and formatters such as Black and Ruff enforce it automatically. The only hand-authored reason to switch is to avoid a backslash escape when the content contains the delimiter:

python
note = "It's a great day"      # apostrophe inside, use double quotes
message = 'She said "hello"'   # double quotes inside, use single quotes
escaped = "She said \"hello\""  # or escape with a backslash

Immutability

Strings are immutable: once you create one, you cannot change it. Think of a string as permanently fixed the moment it is made. Any operation that looks like it is modifying a string is actually producing a brand new one. The original stays exactly as it was.

Strings are immutable: no method modifies a string in place. Every operation that transforms text returns a new string and leaves the original untouched. The practical consequence is that a method call you do not assign anywhere has no effect on anything.

str objects are immutable, meaning their contents are fixed once created and nothing can write into them. This gives strings two properties you actually use: they are hashable (a value whose contents never change can be filed by those contents, which is what lets a string serve as a dictionary key or set member), and they are safe to pass around between names without anyone copying them, since no one can change a shared string out from under you.

python
name = "alice"
name = name.upper()   # "ALICE" is a new string; "alice" is unchanged

The direct consequence: you cannot change a character at a specific position. Python will raise an error if you try.

python
name = "alice"
name[0] = "A"   # TypeError: 'str' object does not support item assignment

To get a modified string, build a new one using slicing or a method. Both are covered below.

Attempting character assignment shows the constraint directly:

python
name = "alice"
name[0] = "A"   # TypeError: 'str' object does not support item assignment

When you need a modified version, the standard tools are slicing with concatenation for positional edits, and replace() for substitutions. Both produce a new string and leave the original untouched.

Assigning to a position (name[0] = "A") raises TypeError every time, with no exception. For a positional edit, build a new string by slicing around the part you want to change: name[:1].upper() + name[1:] capitalises the first character. For a substitution anywhere in the string, replace().

JunoImmutability A string never changes once it exists. Anything that looks like it edits one hands you back a new string instead, so assign the result or it slips away. The one that caught me early: name[0] = "A" doesn't work, it raises a TypeError.
JunoImmutability No string method edits in place, every one returns a new string and leaves the original alone. So name.upper() on its own does nothing useful, you have to assign it. And name[0] = "A" raises TypeError, there's no in-place character swap.
JunoImmutability Immutability is what makes strings hashable, so they work as dict keys, and safe to share without copying. Practical fallout: assign the result of every transforming method, since none touch the original. For a positional edit, slice around the spot and rebuild, since item assignment always raises TypeError.

Indexing and slicing

Every character in a string has a numbered position, starting at zero. You can read individual characters by putting that position number in square brackets. Negative numbers count backward from the end.

Strings are sequences with zero-based indexing. Negative indices count from the end. Slicing extracts any contiguous range in a single expression, and it never raises an error on out-of-range values.

A string behaves like an ordered sequence you can index and slice. Reading a single position (s[i]) raises IndexError if the index points past the end. Slicing (s[start:stop:step]) behaves differently: out-of-range bounds are silently clamped to what exists, so a slice can never raise IndexError. That difference is the practical one to hold on to: index access is strict, slicing is forgiving.

python
word = "Python"
#       012345

print(word[0])    # "P"
print(word[2])    # "t"
print(word[5])    # "n"
print(word[-1])   # "n"  (last character)
print(word[-2])   # "o"  (second to last)

-1 is always the last character, -2 the second to last, and so on. They are useful when you want the end of a string without knowing its exact length.

Negative indices wrap: -1 is len(s) - 1, -2 is len(s) - 2. Most useful for end-anchored access when you do not want to compute the length manually. A negative index that goes out of range still raises IndexError, same as a positive one.

A negative index is converted to len(s) + i before the bounds check, so -1 lands on the last character and -2 on the one before it. Going out of range raises IndexError either way, positive or negative: only slicing forgives that, plain indexing does not.

Slicing extracts a chunk. [start:stop] includes start and excludes stop:

python
word = "Python"

print(word[0:2])   # "Py"     (positions 0 and 1)
print(word[2:])    # "thon"   (position 2 to end)
print(word[:3])    # "Pyt"    (start to position 2)
print(word[:])     # "Python" (a copy of the whole string)
print(word[::2])   # "Pto"    (every second character)
print(word[::-1])  # "nohtyP" (reversed)

Three patterns to reach for most: word[:n] for the first n characters, word[n:] for everything from position n onward, word[-n:] for the last n characters. word[::-1] reverses a string. It looks odd the first time, but it is idiomatic Python and you will see it often.

Unlike direct indexing, slicing never raises IndexError. Python clamps out-of-range indices silently, so word[100:] on a short string returns "" rather than crashing. The step argument controls stride: word[::2] takes every other character, word[::-1] traverses in reverse.

In s[start:stop:step], any part you leave out defaults to "the natural end for the direction you are going", not to a fixed 0 and len(). With a positive step that means start-to-end; with a negative step it flips, so start becomes the last character and stop runs off the front. That is why s[::-1] walks the whole string in reverse without you writing any bounds, and the same reason s[::-2] gives you every second character backwards.

JunoIndexing and slicing Positions start at zero, so word[0] is the first character and word[-1] is the last. A slice grabs a range: word[start:stop] keeps start and stops right before stop. word[::-1] reverses the string, which looks strange the first time and then you use it forever.
JunoIndexing and slicing Index from zero, or from the end with negatives, where word[-1] is the last character. Slices take a range that includes start and excludes stop, and a third step sets the stride, so word[::-1] reverses. The catch worth remembering: indexing past the end raises IndexError, but slicing past it returns what's there instead of failing.
JunoIndexing and slicing Plain indexing is strict and raises IndexError out of range, slicing is forgiving and clamps to whatever exists, so word[100:] returns "" rather than raising. Leave a slice part out and it defaults to the natural end for the step's direction, which is the whole reason word[::-1] reverses with no bounds written. Reach for a slice when you'd rather not crash on an empty or short input.

Essential string methods

Strings come with a set of built-in methods: operations you call directly on any string value. You write the string (or the variable holding it), then a dot, then the method name. Each method returns a new string. The original is never changed.

String methods are functions attached to the str type. Because strings are immutable, every method returns a new string rather than modifying the original. A method call you do not assign or pass somewhere has no lasting effect.

Every transforming method returns a new str and leaves the original alone, which follows straight from immutability. They also work on code points (the characters themselves, not the raw bytes used to store them), so methods behave correctly on accented and non-Latin text without you doing anything special. The trade-off to keep in mind: each method allocates a new string, so chaining many of them over large text means many allocations.

Case

python
text = "Hello, World"

text.lower()       # "hello, world"
text.upper()       # "HELLO, WORLD"
text.title()       # "Hello, World"  (each word capitalised)
text.capitalize()  # "Hello, world"  (first word only)

lower() and upper() are the two you will use most. lower() is particularly useful when comparing text: "Alice" and "alice" become the same thing once you call .lower() on both sides.

lower() is the standard normalisation step before comparison or storage. title() capitalises the first letter of each word using a rough rule that misfires on contractions: "it's" becomes "It'S". Treat it as display-only formatting.

lower() applies Unicode full case conversion. For case-insensitive comparison, casefold() is more correct: it applies additional transformations (e.g. German ß becomes ss) that lower() skips. title() capitalises after any non-alphanumeric character, which mishandles contractions and hyphenated names. For correct title casing, implement the logic manually.

Whitespace

python
text = "  hello  "

text.strip()    # "hello"    (both sides)
text.lstrip()   # "hello  "  (left only)
text.rstrip()   # "  hello"  (right only)

strip() removes spaces from both ends of a string. You will use it almost any time you handle user input or text from a file, because stray spaces cause silent failures: "alice" != "alice ".

strip() removes all leading and trailing whitespace: spaces, tabs, and newlines. The directional variants let you clean only one side, useful for stripping a trailing newline without touching indentation. All three accept an optional characters argument to strip specific characters instead.

With no argument, strip() removes every kind of whitespace from both ends, including non-ASCII whitespace, not only the plain space. With a character argument it removes any of those characters from the ends, and that is the trap: the argument is a set of characters to strip, not a prefix to match. "xxhelloxx".strip("x") returns "hello", but "https://".strip("https") does not remove the prefix "https", it strips every h, t, p and s from both ends and returns "://". To remove a known prefix or suffix, use removeprefix() and removesuffix() instead.

Finding

python
text = "Hello, world"

text.find("world")         # 7
text.find("Python")        # -1  (not found)
text.count("l")            # 3
text.startswith("Hello")   # True
text.endswith("world")     # True

find() returns the position where a piece of text starts inside your string. If it is not there, it returns -1. Use startswith() and endswith() when you only care whether the string begins or ends with something specific.

find() returns the start index of the first match, or -1. The -1 convention lets you use the result directly in slicing or arithmetic without a check. startswith() and endswith() each accept a tuple of strings, so you can test several prefixes or suffixes in one call.

find() scans left to right and returns -1 when there is no match. index() does the same search but raises ValueError instead of returning -1: pick index() when a missing match means your code has a bug and you want it to stop there, find() when absence is normal input you will check for. For a pure "does it start or end with this" question, startswith() and endswith() say what you mean and stop at the first mismatch, so prefer them over a find() or in check for that job.

Replacing

python
text = "Hello, world"

text.replace("world", "Python")   # "Hello, Python"
text.replace("l", "L")            # "HeLLo, worLd"  (all occurrences)
text.replace("l", "L", 1)         # "HeLlo, world"  (first only)

replace() swaps every occurrence of one piece of text for another and gives you back a new string. The original is not changed. Pass a third argument if you only want to replace the first occurrence.

replace() replaces all non-overlapping occurrences by default. The count argument caps how many get replaced. Since it returns a new string, calls can be chained: text.replace("a", "A").replace("e", "E") applies both substitutions in sequence.

replace() matches a literal substring, not a pattern, so it does no regex and treats every character in the argument as itself. It replaces non-overlapping matches left to right, which can surprise you: "aaa".replace("aa", "b") gives "ba", not "bb", because the second aa overlaps the first match. The count argument caps how many it replaces, and since every call returns a new string you can chain them: text.replace(",", "").replace(" ", "_").

Splitting and joining

split() cuts a string into pieces at a separator and returns them as a list. You tell it what to cut on:

split() partitions at a separator and returns the segments as a list. Called with no argument, it splits on any whitespace run and discards empty strings from multiple consecutive spaces:

split(sep) scans left-to-right, splitting at every non-overlapping occurrence of sep. With no argument it uses a different algorithm: it splits on any run of whitespace and strips leading and trailing whitespace from the result. rsplit(sep, n) splits from the right, useful for isolating the last segment of a dotted path or namespaced identifier.

python
csv_row = "Alice,28,London"
parts = csv_row.split(",")     # ["Alice", "28", "London"]

"  hello   world  ".split()   # ["hello", "world"]

split() returns a list, an ordered sequence of values. They get their own Lists chapter; for now treat them as the sequence of parts split() produces and join() consumes.

join() does the reverse: it combines a list of strings into one. The string before .join() is placed between each item:

python
words = ["Hello", "world"]

" ".join(words)    # "Hello world"
", ".join(words)   # "Hello, world"
"".join(words)     # "Helloworld"

The pattern to remember: separator.join(list_of_strings). The separator goes on the left, the list on the right. " ".join(words) puts a space between each word. "".join(words) glues them with nothing between.

join() is the right tool whenever you are assembling a single string from multiple pieces. It performs a single allocation rather than creating a new string at each step. For two or three strings, + is perfectly fine. Once you have a list of any significant size, reach for join().

join() walks the pieces once, works out the total size, allocates the result a single time, and writes everything in. Building the same string with repeated + in a loop is the expensive shape: because strings can't change, each + allocates a fresh string and copies everything so far into it, so the cost grows with the square of the number of pieces. On a handful of strings nobody notices, but on a few thousand it turns a fast operation slow. The rule that prevents it: collect the pieces in a list and join() once at the end, never += a string in a loop.

JunoEssential string methods Every one of these returns a new string, so assign the result or it's gone. The handful you'll reach for daily: .lower() and .upper() for case, .strip() to trim stray spaces, .find() to locate text (it returns -1 when it's not there), .replace() to swap text, and .split() with sep.join() to take a string apart and put it back together.
JunoEssential string methods All of these return a new string, none mutate, so chain them or assign them. Keep the gotchas straight: .strip("x") takes a set of characters to trim, not a prefix, and .split() with no argument collapses runs of whitespace. For assembling many pieces, build a list and sep.join() it.
JunoEssential string methods The two traps that bite in real code: .strip(chars) strips a character set from the ends, not a prefix, so use removeprefix() when you mean a prefix, and building a string with += in a loop is the slow shape, so collect into a list and join() once. For comparison, normalise with .casefold() over .lower(), and treat .title() as display-only since it mangles contractions.

f-strings

f-strings embed values directly inside text. Put f before the opening quote, then wrap any variable or expression in curly braces. Python fills it in when the code runs. You can also add a colon after the value to control how it is displayed.

f-strings evaluate any expression inside {} at runtime and convert the result to a string. A colon inside the braces introduces a format spec: a compact syntax for controlling decimal places, alignment, and number formatting.

Each {} in an f-string ends up calling the value's own __format__ method (a dunder, the double-underscore method Python calls behind the scenes when it formats a value), passing along whatever you wrote after the colon. So any class you write can decide how it appears inside an f-string by defining __format__. The conversion flags !r, !s, and !a run repr(), str(), or ascii() on the value first; !r is the one to keep, because it shows a value the way you would type it in code, quotes and escapes included, which is shown in practice below.

python
name = "Alice"
score = 94.5

print(f"Hello, {name}!")           # "Hello, Alice!"
print(f"Score: {score:.1f}%")      # "Score: 94.5%"
print(f"2 + 2 = {2 + 2}")          # "2 + 2 = 4"
print(f"Name: {name.upper()}")     # "Name: ALICE"

The format spec after : controls how the value is displayed:

SpecMeaningExample
.2f2 decimal placesf"{3.14159:.2f}""3.14"
.0%percentage, no decimalsf"{0.94:.0%}""94%"
,thousands separatorf"{1000000:,}""1,000,000"
>10right-align in 10 charsf"{'hi':>10}"" hi"

You will use .2f most: any time you display a decimal and want a tidy number rather than a long run of digits. Everything else in the table is there when you need it. You can put any variable, arithmetic, or method call inside the {}.

.2f and .0% cover most display formatting. The alignment specifiers (>, <, ^) produce tabular output when combined with a width. The general pattern is {value:[align][width][.precision][type]}. Once you recognise the pieces, any spec is readable without memorising all combinations.

The same spec can mean different things to different types, because each type formats itself: f"{'hi':5}" pads text on the right while f"{42:5}" pads a number on the left, same :5, opposite result. The conversion flag worth a habit is !r: it runs repr() before formatting, which wraps strings in quotes and turns invisible characters (tabs, trailing spaces, newlines) into visible escape sequences. When output looks subtly wrong, swapping {value} for {value!r} is the fastest way to see what is actually in there.

Junof-strings Put f before the opening quote, then wrap any variable, sum, or method call in {} and Python drops the result in when the line runs. A colon inside the braces controls the look: :.2f for two decimal places is the one you'll lean on. So much tidier than gluing text together with +.
Junof-stringsf"..." evaluates any expression in {} and drops the result straight in, no str() needed. After a colon comes the format spec: :.2f, :,, :>10 and friends, all following {value:[align][width][.precision][type]}. Learn the parts once and you can read any spec without memorising them.
Junof-strings Each {} calls the value's own __format__, so the spec after the colon means whatever that type decides, which is why :5 pads text and numbers in opposite directions. The flag to keep is !r: it shows a value the way you'd type it, so stray whitespace and other invisible junk jump out the moment output looks off.

Multiline strings

To write a string that spans more than one line, use triple quote marks: three " at the start and three at the end. Python preserves all the line breaks and spacing exactly as you wrote them.

Triple-quoted strings preserve all whitespace and line breaks literally. They are standard for long text blocks such as email templates and SQL queries, and for docstrings: the inline documentation placed at the start of a function or class body.

A triple-quoted string keeps every character exactly, including the leading spaces on each line, which is the catch: indent the literal to match your code and that indentation lands inside the text. When it is the first statement in a function, class, or module, Python keeps it as that object's docstring (the documentation help() shows for it), so the convention is to put a short summary there. To indent a triple-quoted block to match its surroundings without the indentation leaking into the value, strip it back at runtime with textwrap.dedent(). Triple ''' and """ work the same; """ is the convention.

python
message = """
Dear Alice,

Thank you for your order.

Best regards,
The Team
"""
JunoMultiline strings Three quote marks at each end let a string run across several lines, and Python keeps every line break and space exactly as you typed them. Reach for them whenever you've got a block of text, like a message or a template, that won't fit comfortably on one line.
JunoMultiline strings Triple quotes preserve line breaks and spacing verbatim, so they suit email templates, SQL, and any long block. They're also how docstrings work: a triple-quoted string at the top of a function or class becomes its inline documentation. Watch the indentation, since the spaces you add to line it up end up inside the text.
JunoMultiline strings Everything inside triple quotes is kept literally, leading whitespace included, which is the trap: indent the literal to match your code and that indent lands in the value. Put a short one at the top of a function or class and it becomes the docstring that help() shows. When you need it indented in the source but clean in the value, run it through textwrap.dedent().

Escape sequences

Some characters are hard to type directly inside a string. Python uses escape sequences: a backslash followed by a letter that stands for something. The two you will use constantly are \n for a new line and \t for a tab.

Escape sequences let you embed characters that would otherwise break the syntax or cannot be typed directly. The ones you will reach for: \n (newline), \t (tab), \\ (a literal backslash), \" and \' (quotes inside a matching-delimiter string). Windows paths require backslashes, which collide with escape processing. Prefix with r to disable it.

Beyond the everyday \n and \t, Python supports Unicode escapes for characters you can't type directly: \uXXXX and \UXXXXXXXX name a character by its number, and \N{name} names it in words, like \N{GREEK SMALL LETTER ALPHA}. The one to reach for in real code is the raw string, written r"...", which turns off escape handling entirely and passes every backslash through unchanged. Use it for Windows paths and for regular expression patterns, where the backslashes are meant for that consumer rather than for Python, and where forgetting r produces patterns that quietly match the wrong thing.

SequenceCharacter
\nNewline
\tTab
\\Literal backslash
\"Double quote
\'Single quote
python
print("Line one\nLine two")        # two lines of output
print("Name:\tAlice")              # Name:   Alice
path = r"C:\Users\Alice\Documents" # raw string, no escape processing
JunoEscape sequences A backslash inside a string means "read the next character specially": \n starts a new line, \t inserts a tab, \\ is one real backslash. Those two, \n and \t, are the ones you'll actually type. Pop an r before the quote and backslashes go back to being plain, handy for Windows paths.
JunoEscape sequences\n, \t, \\, and \" cover the everyday escapes. The one that saves headaches: a raw string, r"...", switches escape processing off so every backslash stays literal. Use it for Windows paths and regex patterns, where the backslashes are meant for something other than Python.
JunoEscape sequences Past \n and \t there are Unicode escapes like \N{name}, but the workhorse is the raw string r"...", which passes every backslash through untouched. Reach for it on regex patterns and Windows paths, since forgetting it gives you a pattern that quietly matches the wrong thing rather than an error that tells you.

Checking string contents

Python has methods that answer yes/no questions about what a string contains. They return True or False. The most useful early on: isdigit() lets you check whether a string is all numbers before converting it, so you can avoid a crash on unexpected input.

The is* methods each test a specific property of the entire string and return True only if every character satisfies the condition. Their main use is input validation: check before converting to avoid a crash on unexpected input. isdigit() before int() is the classic pattern, an alternative to catching the ValueError covered in the Files and exceptions chapter.

These checks ask "is every character of this kind" across all of Unicode, not only the plain ASCII letters and digits. That means "2".isdigit() is True for the superscript two as well as the ordinary "2", which can let surprising input slip past a validation check. When you mean strictly the digits 0 to 9, combine s.isascii() and s.isdigit(). isnumeric() is wider still, counting fractions and other numeric-valued characters, so reach for the narrowest check that matches what you actually accept rather than the first one that looks right.

python
"42".isdigit()       # True
"hello".isalpha()    # True
"hello42".isalnum()  # True
"   ".isspace()      # True
"Hello".islower()    # False
"HELLO".isupper()    # True
JunoChecking string contents The is* methods answer yes-or-no questions and return True only when every character fits. The one you'll use first: call isdigit() before int() to make sure the text really is a number, so odd input doesn't crash you.
JunoChecking string contents Each is* method is True only if the whole string passes, which makes them handy for validating input before converting. isdigit() before int() is the classic guard. Remember it's testing the entire string, so an empty string returns False for all of them.
JunoChecking string contents These checks span all of Unicode, not plain ASCII, so isdigit() passes superscripts and other numeric characters you didn't mean to accept. When you mean the digits 0 to 9, pair it: s.isascii() and s.isdigit(). Pick the narrowest check that matches your real input rather than the first that looks close.

In practice

Strip whitespace, normalise case, then pull out what you need. This sequence handles almost any user-provided text:

python
raw_input = "  Alice@Example.COM  "
email = raw_input.strip().lower()   # "alice@example.com"

at_pos = email.find("@")
username = email[:at_pos]
domain = email[at_pos + 1:]

print(f"User:   {username}")    # "alice"
print(f"Domain: {domain}")      # "example.com"

Building a URL from parts and immediately validating and parsing it:

python
BASE_URL = "https://api.example.com"
version = "v2"
resource = "users"
user_id = 42

url = f"{BASE_URL}/{version}/{resource}/{user_id}"
# "https://api.example.com/v2/users/42"

parts = url.split("://")              # ["https", "api.example.com/v2/users/42"]
protocol = parts[0]                   # "https"
secured = url.startswith("https")
domain = parts[1].split("/")[0]       # "api.example.com"

print(f"Protocol : {protocol}")
print(f"Secure   : {secured}")
print(f"Domain   : {domain}")

Parsing a structured log line using find(), slicing, and f-string alignment:

python
log_entry = "[2024-01-15 09:42:11] ERROR: File not found: report.csv"

timestamp = log_entry[1:20]
rest = log_entry[22:]                # "ERROR: File not found: report.csv"
colon_pos = rest.find(":")
level = rest[:colon_pos]             # "ERROR"
message = rest[colon_pos + 2:]       # "File not found: report.csv"

print(f"[{timestamp}] {level:>8}: {message}")
# [2024-01-15 09:42:11]    ERROR: File not found: report.csv

find() locates the boundary, slicing extracts the parts, and the >8 format spec right-aligns the severity label so columns stay consistent when level names differ in length.

Method reference

MethodWhat it does
.lower() / .upper()Convert to all lowercase / all uppercase
.title() / .capitalize()Capitalise each word / only the first
.strip() / .lstrip() / .rstrip()Remove surrounding whitespace
.find(sub)Index of first match, or -1
.count(sub)How many times sub appears
.startswith(s) / .endswith(s)Prefix / suffix check
.replace(old, new)Replace occurrences
.split(sep)Split into a list
sep.join(iterable)Join items into a string
.isdigit() / .isalpha() / .isalnum()Character type checks