Python String Slicing

String slicing is how you pull a piece — or a rearranged copy — out of a Python string without writing a single loop. Instead of manually walking through characters with indices, you describe a range with a compact [start:stop:step] syntax, and Python hands you back a brand-new string containing exactly what you asked for. Slicing is one of the most-used tools in everyday Python because it works identically on strings, lists, and tuples, and because it never raises an error even when your range is “wrong” — it just clamps to what’s available.

Overview / How It Works

A Python string is a sequence of characters, and every sequence supports two related but different operations: indexing and slicing. Indexing with s[i] asks for a single character at position i and raises IndexError if that position doesn’t exist. Slicing with s[start:stop] asks for a range of positions and always succeeds, returning an empty string in the worst case rather than crashing.

Internally, when you write s[start:stop:step], Python builds a slice object — the same object you’d get by calling slice(start, stop, step) directly — and passes it to the string’s __getitem__ method. The string then calls slice.indices(len(s)), which normalizes your (possibly negative, possibly out-of-range, possibly omitted) numbers into a safe, in-bounds triple of actual integers. That normalized triple is what actually gets used to walk the underlying character data and build the new string. This is why slicing is so forgiving: the clamping happens before any character is ever touched.

Because Python strings are immutable, slicing never modifies the original string — it always constructs a new string object. The original is completely untouched, no matter how the slice is used later.

Indices in a string of length n run from 0 to n - 1 counting forward, and from -1 to -n counting backward from the end. Index -1 is always the last character, -2 the second-to-last, and so on. Slicing lets you freely mix positive and negative indices in the same expression.

Syntax

sequence[start:stop:step]

All three parts are optional, and each has a sensible default when omitted:

Part Meaning Default if omitted
start Index of the first character to include 0 (or len(s) - 1 if step is negative)
stop Index to stop before (never included) len(s) (or -len(s) - 1, effectively “before the start”, if step is negative)
step How many positions to advance each time; negative reverses direction 1

The single most important rule to memorize: stop is exclusive. s[2:5] gives you characters at indices 2, 3, and 4 — three characters, not four.

Examples

Example 1: Basic slicing with start and stop

message = "Hello, Python World!"

# Basic slicing: [start:stop]
greeting = message[0:5]
print(greeting)

subject = message[7:13]
print(subject)

# Omitting start or stop
first_word = message[:5]
print(first_word)

rest = message[7:]
print(rest)

# Full copy
copy_of_message = message[:]
print(copy_of_message)

Output:

Hello
Python
Hello
Python World!
Hello, Python World!

Notice that message[0:5] and message[:5] produce the same result — when start is 0 you can simply leave it out. Also notice message[:]: an empty slice copies the entire string. This is a common idiom for making an explicit copy of a sequence (though for immutable strings there’s rarely a practical need, since the original can’t change anyway).

Example 2: Negative indices and the step value

text = "Programming"

print(text[-1])
print(text[-4:])
print(text[:-4])
print(text[::-1])
print(text[::2])
print(text[1:-1:2])

Output:

g
ming
Program
gnimmargorP
Pormig
rgamn

text[-1] grabs the last character. text[-4:] means “start 4 characters from the end, go to the end,” giving "ming". text[:-4] means the opposite: everything up to (but not including) the last four characters. text[::-1] is the famous “reverse a string” idiom — an empty start and stop with step = -1 walks the whole string backward. text[::2] takes every second character starting from the beginning, and text[1:-1:2] combines all three parts: start at index 1, stop before the last character, and step by 2.

Example 3: A realistic use — truncating text and reading file extensions

def truncate(text: str, max_length: int) -> str:
    if len(text) <= max_length:
        return text
    return text[:max_length - 3] + "..."


def get_file_extension(filename: str) -> str:
    dot_index = filename.rfind(".")
    if dot_index == -1:
        return ""
    return filename[dot_index + 1:]


headline = "Python slicing lets you extract substrings without loops"
short_headline = truncate(headline, 30)
print(short_headline)

filenames = ["report.pdf", "archive.tar.gz", "README"]
for name in filenames:
    ext = get_file_extension(name)
    print(f"{name} -> {ext!r}")

Output:

Python slicing lets you ext...
report.pdf -> 'pdf'
archive.tar.gz -> 'gz'
README -> ''

truncate uses text[:max_length - 3] to leave exactly enough room for the trailing "...". get_file_extension combines str.rfind (which locates the last dot, important for names like archive.tar.gz) with a slice from just after that dot to the end. When there’s no dot at all, rfind returns -1 and the function returns an empty string instead of slicing incorrectly.

Under the Hood: How Python Resolves a Slice

When Python evaluates s[start:stop:step], it goes through roughly these steps:

  • The three values (or their defaults) are packaged into a slice object: slice(start, stop, step).
  • The string’s length, len(s), is used to call slice.indices(len(s)), which returns a normalized (start, stop, step) triple where every negative index has been converted to its positive equivalent and every out-of-bounds value has been clamped into the valid range [0, len(s)].
  • Python walks the string from the normalized start to the normalized stop, advancing by step each time, collecting each character it lands on.
  • A new string object is allocated and returned containing exactly those characters, in order.

You can see this normalization directly:

s = "Slicing"
print(slice(2, 100).indices(len(s)))

This prints (2, 7, 1) — Python has already clamped the impossible stop=100 down to the string’s actual length, 7. That clamping is precisely why slicing never raises IndexError even when your numbers overshoot: by the time any character lookup happens, the range has already been made safe.

Common Mistakes

Mistake 1: Forgetting that stop is exclusive

It’s easy to assume a slice includes the character at the stop index. This leads to off-by-one bugs, especially when extracting a fixed-width field from a formatted string:

filename = "report_2024.csv"
wrong_year = filename[8:11]
print(wrong_year)

Output:

024

The digits "2024" actually start at index 7, not 8, so this slice cuts off the leading "2" and grabs one character too many from the wrong end. Counting indices carefully (or better, locating the boundary programmatically instead of hardcoding numbers) fixes it:

filename = "report_2024.csv"
year = filename[7:11]
print(year)

Output:

2024

Mistake 2: Expecting a slice to raise IndexError like direct indexing does

Because s[i] raises IndexError for an out-of-range i, many beginners assume s[start:stop] will too. It won’t — slicing silently clamps to whatever exists, which can hide bugs where you expected a crash to alert you to bad input:

data = "abc"
print(data[10:20])
print(data[1:100])
print(data[-100:2])

Output:


bc
ab

data[10:20] is entirely past the end of a 3-character string, so it quietly returns an empty string instead of erroring. data[1:100] clamps its huge stop down to the actual length. data[-100:2] clamps its huge negative start up to 0. If you actually need to know whether an index is valid — for example while parsing untrusted input — check len(s) explicitly rather than relying on slicing to catch the problem for you, since data[10] (plain indexing, no colon) is the version that would raise IndexError.

Best Practices

  • Remember stop is exclusive — read s[a:b] as “b - a characters starting at a” to avoid off-by-one mistakes.
  • Prefer s[:n] and s[n:] over s[0:n] and s[n:len(s)] — the shorter forms are idiomatic and communicate “from the start” / “to the end” clearly.
  • Use negative indices (s[-n:]) instead of computing len(s) - n manually when you want the last n characters.
  • For readability, prefer built-in string methods over manual slicing when one exists: use s.startswith(prefix) instead of s[:len(prefix)] == prefix, and s.removeprefix(p) / s.removesuffix(p) (Python 3.9+) instead of hand-rolled slices for trimming known prefixes or suffixes.
  • If a slice’s start/stop values are meaningful (not just “first 5 characters”), give them names — e.g. header, body = line[:HEADER_LEN], line[HEADER_LEN:] — rather than leaving magic numbers in the code.
  • Use s[::-1] for reversing short strings; for very large strings or when clarity matters more than brevity, consider "".join(reversed(s)) instead.
  • Don’t rely on slicing to validate input ranges — it clamps silently instead of erroring, so add explicit bounds checks where correctness matters.

Practice Exercises

  • Write a function middle_initial(full_name: str) -> str that takes a string like "Ada Marie Lovelace" and returns just the middle name’s first letter followed by a period, e.g. "M.", using slicing (not split) on the already-located middle name.
  • Given phone = "+1-415-555-0182", use slicing to extract the area code "415" and the last four digits "0182" into two separate variables, then print them as "Area: 415, Last4: 0182".
  • Write a function is_palindrome(word: str) -> bool that uses a single slicing expression to check whether a string reads the same forwards and backwards (e.g. "level" is a palindrome, "world" is not).

Summary

  • Slicing uses s[start:stop:step] to extract a substring; all three parts are optional and have defaults (0, len(s), and 1 respectively).
  • stop is always exclusive — the character at that index is never included.
  • Negative indices count from the end of the string, with -1 being the last character.
  • A negative step walks backward, which is how s[::-1] reverses a string.
  • Slicing is forgiving: out-of-range start/stop values are clamped rather than raising an error, unlike direct indexing with s[i].
  • Slicing always returns a brand-new string, since strings are immutable — the original is never modified.
  • Prefer built-in methods (startswith, removeprefix, etc.) over manual slicing when they express the same intent more clearly.