Skip to content

Four ways to find it, and four ways to fail

Level: 201 · for Python programmers

One line: in, find, index and partition all locate the same substring in the same place, and everything that separates them happens on the miss — where the two that look safest, find's -1 and partition's tuple that never raises, are the two that let a failed search flow into the next line as if it had worked.

Four things in str answer "where is this substring". On a hit they agree: in says True, find and index hand back the same integer, partition hands back the part before, the separator itself, and the part after. If your data always contains what you are looking for, the choice between them is a matter of taste and it does not matter which one you learned first.

The choice is not about the hit. It is about what each one does when the answer is "it is not there", and there the four spread out as far apart as they can get. in returns False — the only thing it can say, and therefore the only thing it can be misread as. index raises ValueError and stops the program on the line that was wrong. find returns -1. partition returns a three-tuple with an empty middle field. Those last two are not errors and do not look like errors; they are ordinary values that the next line of code will use.

The docs attach a note to find about precisely this: use it only if you need to know the position, and use in for the yes/no question. That reads like style advice, and it is not. -1 is a valid index in Python, so a find result that nobody checked does not blow up — it addresses the last character of the string.

Verified output of finding_a_substring_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. FOUR WAYS TO ASK, ONE ANSWER
     line                     'spam, spam, eggs and spam'
     'eggs' in line           True
     line.find('eggs')        12
     line.index('eggs')       12
     line.partition('eggs')   ('spam, spam, ', 'eggs', ' and spam')

     All four found the same thing in the same place. Nothing on
     this page is about the hit.

2. THE SAME FOUR ON A MISS -- THIS IS THE WHOLE LESSON
     the needle is 'Lancelot', which is not in the line

     'Lancelot' in line          False    a bool, and you have to test it
     line.find('Lancelot')       -1       an int -- and a valid index
     line.index('Lancelot')      ValueError: substring not found
     line.partition('Lancelot')  ('spam, spam, eggs and spam', '', '')

     One of the four says so out loud. Two hand back a value that
     the next line of code will happily use. And 'in' cannot be
     misread, because False is the only thing it knows how to say.

3. THE TRUTH VALUE OF find() IS EXACTLY BACKWARDS
     0 means 'found, at the very start' and is falsy.
     -1 means 'not there at all' and is truthy.

     needle        find  bool(r)   r > 0  r > -1  r >= 0
     --------------------------------------------------
     'spam'           0    False   False    True    True
     'eggs'          12     True    True    True    True
     'Lancelot'      -1     True   False   False   False

     Read bool(r) against the last two columns. Only a comparison
     with a number agrees with 'in' on all three rows:

       if line.find(needle):          WRONG -- False at position 0,
                                      and True on a miss. Both ways.
       if line.find(needle) > 0:      WRONG -- False at position 0.
       if line.find(needle) > -1:     right
       if line.find(needle) >= 0:     right
       if needle in line:             right, and it says what it means

     The first one is not one bug, it is two: it misses a match at
     the start and it accepts a miss anywhere.

4. -1 IS A VALID INDEX, WHICH IS WHY THE MISS STAYS QUIET
     at = line.find('Lancelot')   ->  -1
     line[at:]   'm'
                 -- the last character, not ''
     line[:at]   'spam, spam, eggs and spa'
                 -- everything except the last character
     line[at]    'm'
                 -- no IndexError either: -1 counts from the end

     On a hit (at = 12) the same line is what you meant:
     line[at:]   'eggs and spam'

     Nothing raised. Nothing logged. A search that found nothing
     returned the last character of the string. That is the bug
     the docs are steering you away from when they say to use
     find only if you need the position.
     line.index('Lancelot') would have stopped here instead:
       ValueError: substring not found

5. partition AND rpartition FAIL IN MIRROR IMAGE
     'Monty Python'.partition('-')    ('Monty Python', '', '')
     'Monty Python'.rpartition('-')   ('', '', 'Monty Python')

     Neither raised. The separator field is empty in both, and
     that is the only field that tells you anything went wrong.
     The string itself moved from the FIRST slot to the THIRD.

     head, sep, tail = title.partition('-')    head 'Monty Python'   tail ''
     head, sep, tail = title.rpartition('-')   head ''               tail 'Monty Python'
     Two lines that differ by one letter put your data in
     different variables, and neither one complains.

     They differ on a hit too -- first separator against last:
       'a-b-c'.partition('-')    ('a', '-', 'b-c')
       'a-b-c'.rpartition('-')   ('a-b', '-', 'c')

6. THE MIRROR IS DELIBERATE: EACH ONE IS RIGHT FOR ITS OWN JOB
     partition splits off the FIRST field and keeps the rest:
       'timeout=30'   key 'timeout'    value '30'
       'DEBUG'        key 'DEBUG'      value ''
       A flag with no '=' is a key with no value. Right answer.

     rpartition splits off the LAST field and keeps the rest:
       'usr/share/doc/spam.txt'   dir 'usr/share/doc'      name 'spam.txt'
       'spam.txt'                 dir ''                   name 'spam.txt'
       A bare filename is a name with no directory. Right answer.

     In both cases the whole string lands in the field that wanted
     it. Pick by which end you are scanning from -- and then read
     the separator field, because it is the one that knows.

7. THE UNPACK THAT RAISES, AND THE ONE THAT NEVER DOES
     key, value      = 'DEBUG'.split('=', 1)
       ValueError: not enough values to unpack (expected 2, got 1)
     key, sep, value = 'DEBUG'.partition('=')
       ('DEBUG', '', '')

     Same job, two failure styles. partition's tuple is three
     long whatever happens, so the unpack is total and the
     traceback never arrives. The separator field is falsy on a
     miss and truthy on a hit -- 'if not sep' is the test, and
     no other field in the tuple can answer the question.

8. start AND end ARE SLICE NOTATION -- THE RESULT IS NOT
     s = 'spam, spam'
     s.find('sp', 5)       6   an offset into s
     s[5:].find('sp')      1   an offset into the slice
     s[5:] is ' spam'

     The window is s[start:end]; the number handed back is an
     index into s. That is the useful half -- you can slice with it.

     Out-of-range and negative behave like slicing, not like errors:
       s.find('sp', -4)      6
       s.find('sp', 99)     -1
       s.find('a', -100)     2

     And 'end' can cut a match in half:
       s.find('am', 0, 3)   -1   s[0:3] is 'spa'
       s.find('am', 0, 4)    2   s[0:4] is 'spam'
     'am' sits at index 2 in both. It is the window that moved.

9. THE EMPTY NEEDLE IS EVERYWHERE -- EXCEPT PAST THE END
     '' in 'spam'          True
     'spam'.find('')          0
     'spam'.rfind('')         4   len(s): a slice bound, not an index
     'spam'.count('')         5   n + 1 gaps between n characters
     'spam'.find('', 2)       2
     'spam'.find('', 99)     -1   even '' can be reported missing

     The separator families refuse it outright:
       'spam'.partition('')   ValueError: empty separator
       'spam'.rpartition('')  ValueError: empty separator
       'spam'.split('')       ValueError: empty separator

     So partition is not 'the one that never raises'. It never
     raises about the DATA. It still raises about the SEPARATOR.

10. ON bytes, AN INT IS A NEEDLE
     b'sp' in b'spam'     True
     115 in b'spam'       True   115 is ord('s')
     b'spam'.find(115)    0
     115 in 'spam'        TypeError: 'in <string>' requires string as left operand, not int
     'spam'.find(115)     TypeError   (message reworded in 3.13)

     The same expression means different things on the two types.
     On bytes an int asks about one byte VALUE; on str it is
     refused, because a str has no members that are not strings.

11. THE OFFSET COUNTS CODE POINTS, NOT BYTES
     s = 'żółw'   4 characters, 7 bytes in UTF-8
     s.find('w')              3   code points
     s.encode().find(b'w')    6   bytes
     s[3:] is 'w'

     Both numbers are right; they answer different questions.
     Python's str search counts characters, which is the number a
     person means -- and is not the number a byte buffer wants.

12. WHY THERE ARE TWO NAMES FOR ONE SEARCH
     type           .find  .index
     ----------------------------
     list           False    True
     tuple          False    True
     range          False    True
     str             True    True
     bytes           True    True
     bytearray       True    True

     index belongs to the sequence protocol, and every sequence
     that has it answers a miss the same way:
       [1, 2].index(9)     ValueError
       (1, 2).index(9)     ValueError
       range(2).index(9)   ValueError
       'spam'.index('9')   ValueError
     (the type only: CPython reworded two of those four in 3.14,
      which is why an answer key prints the type and not the text)

     find is text-only: it is the convenience that trades the
     exception for a sentinel. There is no list.find, so there
     is nowhere else in the language to write the -1 bug.

     One more asymmetry in the same direction:
       'spam'.startswith(('sp', 'eg'))   True   a tuple of alternatives
       'spam'.find(('sp', 'eg'))         TypeError   (message reworded in 3.13)
     startswith and endswith take a tuple. find, index, count,
     partition and 'in' all take exactly one needle.

What the run shows

The truth value of find's result is exactly inverted. 0 means found, right at the start and is falsy; -1 means not there at all and is truthy. So if s.find(needle): is wrong twice over in the same expression — it is False on a match at position 0, and True on every miss. It is not an off-by-one; it is the answer upside down.

The backlog row this page closes claimed that if s.find(x): and if s.find(x) > -1: are "both wrong in different ways." Half of that is right. Measured, > -1 and >= 0 both agree with in on every row of section 3 — they are correct, and > -1 reads a little worse than >= 0 only because the sentinel is written down twice. The two shapes that actually fail are if s.find(x): and if s.find(x) > 0:, which fail identically and for the same reason: position 0 is a real position and both of them treat it as nothing. (CONTRIBUTING says to expect about one hook in three to come out different once a program is pointed at it. This is one of them, and the measured version is the page.)

The failure is quiet because the sentinel is also a valid index. Section 4 is the whole argument for index, in three lines: at = line.find('Lancelot') is -1, and then line[at:] is 'm', line[:at] is everything except the last character, and line[at] is 'm' again with no IndexError — because -1 counts from the end. A search that found nothing returned the last character of the string, and nothing raised, nothing logged, nothing in the output looks unusual. The same line written with index stops at ValueError: substring not found, at the call, with the needle in the traceback.

That is the rule worth carrying: the method that raises is the safe one. find is for the case where "not present" is an expected outcome you are about to branch on; index is for the case where "not present" means your assumption about the data was wrong, which is most cases. If you find yourself writing if s.find(x) >= 0: you wanted if x in s:, and if you find yourself using the result without a check you wanted index.

partition and rpartition fail in mirror image, and the mirror is deliberate. Neither raises, so the unpack is always safe — head, sep, tail = s.partition(sep) cannot produce a ValueError, ever. The cost is that the miss has to be read out of the shape of the result: the separator field is empty, and the string itself has moved. 'Monty Python'.partition('-') is ('Monty Python', '', '') and 'Monty Python'.rpartition('-') is ('', '', 'Monty Python'). Two calls that differ by one letter put your data in different variables and neither one says a word.

That asymmetry is not an accident, and section 6 is the reason to stop resenting it. partition splits off the first field: key, _, value = 'DEBUG'.partition('=') gives key == 'DEBUG' and an empty value, which is the right answer for a flag with no value. rpartition splits off the last field: parent, _, name = 'spam.txt'.rpartition('/') gives an empty directory and name == 'spam.txt', which is the right answer for a bare filename. In each case the whole string lands in the field that wanted it. Choose by which end you are scanning from — and then read the separator field, because on a miss it is the only field that knows.

The separator field is the test, and it is the only one that works. sep is falsy exactly when nothing was found, because a separator can never be '' — section 9 shows partition('') raising ValueError: empty separator. So if not sep: is exact. Checking if not tail: is not: 'a='.partition('=') is ('a', '=', ''), so a real separator at the very end of the string produces an empty tail too.

partition never raising is worth putting beside split, which does. key, value = 'DEBUG'.split('=', 1) raises ValueError: not enough values to unpack (expected 2, got 1) — a loud, immediate, correct failure at the line that was wrong. partition in the same spot returns ('DEBUG', '', '') and carries on. Same job, opposite failure styles, and neither is the better one in the abstract: the question is whether a line without a separator is data you expect or a file you should be refusing.

start and end are slice notation, but the result is not an offset into the slice. 'spam, spam'.find('sp', 5) is 6, while 'spam, spam'[5:].find('sp') is 1. The window is s[start:end]; the number that comes back is an index into s, which is the useful half — you can slice the original with it and never have to add start back on. Everything else about the two arguments really is slicing: negatives count from the end, out-of-range values clamp instead of raising, and end will cut a match in half. 'spam, spam'.find('am', 0, 3) is -1 even though 'am' is at index 2, because the window is 'spa'. It is not a search that stops early at end — it is a search over a shorter string.

The empty needle is found everywhere, and that includes places you would not expect. '' in 'spam' is True, 'spam'.find('') is 0, 'spam'.rfind('') is 4 — which is len(s), a valid slice bound and not a valid index — and 'spam'.count('') is 5, because there are five gaps between and around four characters. The one that catches people is 'spam'.find('', 99), which is -1: even the empty string can be reported missing, once the window you asked for is empty. And the separator family refuses '' outright, so partition is not quite "the one that never raises" — it never raises about the data, and it still raises about the separator.

On bytes, an integer is a needle. 115 in b'spam' is True and b'spam'.find(115) is 0, because 115 is ord('s') and a bytes object's members are integers. The same expression on str is a TypeError: 'in <string>' requires string as left operand, not int. This is the str/bytes boundary showing up in a place nobody looks for it — a search that silently means "one particular byte value" rather than "a substring" is a bug you can write while porting a line from str to bytes without changing it at all.

And the offset counts code points. 'żółw'.find('w') is 3; 'żółw'.encode().find(b'w') is 6. Both are right and they answer different questions. Python's str search counts characters, which is the number a person means when they say "the fourth letter" — and is not the number a byte buffer wants. That is the same split as counting characters, one method along.

Which one to reach for

the question you are asking reach for what a miss looks like
is it in there at all sub in s False
where is it — and "nowhere" is a case I handle s.find(sub), s.rfind(sub) -1, which is a valid index
where is it — and "nowhere" is a bug s.index(sub), s.rindex(sub) ValueError
cut it at the first separator s.partition(sep) (s, '', '') — the whole string first
cut it at the last separator s.rpartition(sep) ('', '', s) — the whole string last
cut it at the first separator, loudly s.split(sep, 1) a one-element list, so the unpack raises
how many times s.count(sub) 0
does it begin or end like this s.startswith(x), s.endswith(x) False — and both take a tuple of alternatives, which find does not
show me the first few characters a slice — s[:5] nothing: a slice that overruns is not an error

The last row is here because it is where one of the questions behind this page started — how do I show the first few characters of a string — and the answer is not on this page at all. It is s[:5], and the reason it belongs in the search family's neighbourhood is that it is the one operation in the group that has no failure mode to compare: "spam"[:10] is 'spam', where "spam"[10] is an IndexError. Slicing has its own invariants worth a page of their own (s[:n] + s[n:] == s for every n, in range or not), and it is still on the backlog rather than absorbed into this one.

One more thing that explains why there are two names for the same search. index is part of the sequence protocollist, tuple and range all have it, and they all raise ValueError — while find exists only on str, bytes and bytearray. There is no list.find, and there never was: find is the text-only convenience that trades an exception for a sentinel, and the sentinel is the thing this page is about.

The Rust view

Rust's str::find returns Option<usize>, so the -1 bug has no way to exist — there is no integer to misuse until you have unwrapped one, and usize has no negative value to unwrap it into. A different bug is available instead, and it is the one that library measures on the same word this page uses: Rust's offset is a byte offset, so "żółw".find('w') is 6 there and 3 here, and feeding a character index to a Rust slice either panics on a character boundary or is quietly short. Two languages, two silent failures, and neither compiler can see the other's.

If you are coming from ABAP

ABAP already separates the two things Python conflates, and it separates them in the direction this page argues for. FIND 'x' IN lv_text reports success through sy-subrc and the position through a separate MATCH OFFSET target — status and value in different places — so there is no single return value that has to mean both "here it is" and "it is not here". The habit that transfers is checking sy-subrc = 0 before touching the offset; the Python spelling of that habit is if x in s: before s.find(x), or just s.index(x) and let it raise. The habit that does not transfer is reading the offset variable when the search failed: in Python that variable is -1 and slicing with it succeeds. CS in a logical expression is in, and it also sets sy-fdpos as a side effect, which is the same "one operation, two answers, one of them easy to read at the wrong moment" shape — check the exact semantics of sy-fdpos on a failed comparison against your own system before relying on either reading.

One unit note worth carrying between all three languages on this page: ABAP's match offset counts characters, because ABAP text is UTF-16 internally. Python counts characters too. Rust counts bytes. So an ABAP offset habit ports to Python unchanged and breaks in Rust the first time a non-ASCII character appears earlier in the string. (Not machine-checked — CI cannot run ABAP.)

Try it

  1. Write the guard four ways — if s.find(x):, if s.find(x) > 0:, if s.find(x) > -1:, if x in s: — and run all four over a needle at position 0, a needle in the middle, and a needle that is absent. Twelve results, and you should be able to say which four are wrong before you run it.
  2. Take a line of key=value config and parse it with partition, then with rpartition, then with split('=', 1). Feed all three a line with no =, a line with two, and an empty line. Which of the three tells you something went wrong, and which of the other two put the data where you would have noticed?
  3. 'spam, spam'.find('am', 0, 3) is -1 and 'am' is at index 2. Write the version of that call which searches the whole string but refuses a match that starts after index 3, and notice how much more code it is than the one you thought you were writing.
  4. Port section 10 to bytes and back. Start from line.find('sp') on a str, change the literal to b'sp' and the haystack to line.encode(), and check that the number is still the same. Then do it with 'żółw' and find the first offset where the two disagree.
  5. 'spam'.rfind('') is 4, which is len(s). Use that result as an index and as a slice bound, and say which of the two is defensible. Then work out what s.rfind('', 0, n) returns for every n from 0 to 10, and check it.

See also