Strings and tuples in Python both raise IndexError on out-of-range indexing, but never on slicing. Knowing the difference between s[5] (raises) and s[5:10] (returns empty string) is the foundation of safe string and tuple access.

Minimal reproducer
s = "hi"
print(s[5]) # IndexError: string index out of range
t = (1, 2, 3)
print(t[5]) # IndexError: tuple index out of range
# But slicing is always safe:
print(s[5:10]) # '' (empty string)
print(t[5:10]) # () (empty tuple)
Fix 1: Check length first
def first_char(s):
return s[0] if s else ''
def nth_item(t, n, default=None):
return t[n] if 0 <= n < len(t) else default
Fix 2: Use slicing to avoid IndexError entirely
s = "hi"
first_two = s[:2] # 'hi' (safe even if shorter)
first_ten = s[:10] # 'hi' (no error, just shorter)
last_char = s[-1:] # 'i' (slice, always safe; vs s[-1] which raises if empty)
Fix 3: Negative index gotcha
s = "ab"
print(s[-1]) # 'b' (valid: -1 = last)
print(s[-2]) # 'a' (valid: -2 = second-to-last)
print(s[-3]) # IndexError: -3 is out of range for length 2
# Safe last-character access:
last = s[-1] if s else ''
# Or use slicing:
last = s[-1:] # '' for empty string, no error
Fix 4: Pattern matching (Python 3.10+)
def describe(t):
match t:
case []:
return "empty"
case [single]:
return f"one item: {single}"
case [first, *rest]:
return f"first={first}, rest={rest}"
# Never raises IndexError, matches based on length
String vs list/tuple behavior table
| Access | String | Tuple/List |
|---|---|---|
| x[i] in-bounds | char | item |
| x[i] out-of-bounds | IndexError | IndexError |
| x[i:j] out-of-bounds | ” (clipped) | () / [] (clipped) |
| x[-1] on empty | IndexError | IndexError |
| x[-1:] on empty | ” | () / [] |
Frequently Asked Questions
Why does s[5:10] not raise IndexError on a 3-char string?
Slicing in Python clamps to valid bounds silently. s[5:10] on “hi” returns ” (empty string). This is by design: slicing represents “give me this range, clamp if you must” while indexing represents “give me exactly this element, fail if missing.”
What is the safest way to get the last character of a possibly-empty string?
Use s[-1:] (slicing) which returns ” on empty string instead of raising. If you need an explicit single char, write last = s[-1] if s else ”. Both are correct; the first is shorter, the second more explicit.
Why does Python use IndexError instead of returning None on out-of-range?
Returning None silently hides bugs. Raising IndexError forces you to handle the case explicitly. For dict-style “missing returns default” semantics, use dict.get() (dicts use KeyError) or wrap list access in your own safe_get function.
Can I use enumerate() to avoid IndexError in loops?
Yes. enumerate() iterates the sequence directly, so you never index past the end. for i, item in enumerate(items): never raises IndexError because i is generated from the actual length. Prefer this over for i in range(len(items)).
Does string slicing copy the data or share memory?
Python strings are immutable and slicing creates a new string object (copy). For very large strings, this can be expensive. Use memoryview on bytes/bytearray for zero-copy slicing of binary data. Pure string slicing always copies.
