The same index used twice, once as an end and once as a start. Look at what that guarantees.
items = [1, 2, 3, 4, 5] index = 2 items[:index] # -> [1, 2] up to but not including items[index:] # -> [3, 4, 5] from there onwards # The boundary is one number used two ways, so nothing can fall between items[:2] + items[2:] == items # True for every index, including items[:99] + items[99:] == items # True items[:-99] + items[-99:] == items # True
- Half-open ranges are why the pieces fit
A Python slice includes its start and excludes its end, so items[:n] stops exactly where items[n:] begins. Every item is in one piece or the other and never both. That is the same convention that makes range(0, n) have n values and makes consecutive ranges join without gaps — it is not an arbitrary choice, it is the choice that makes splitting free of off-by-one errors.
- Slices clamp, so out-of-range indices are already handled
items[:99] gives the whole list and items[99:] gives an empty one, with no exception and no check. That means the two awkward cases at the ends need no code at all — while a version that computes lengths and loops has to think about both. Let the slice do the clamping it was designed for.
- A tuple, because the two pieces are one answer
Returning (before, after) lets the caller write head, tail = split_at(...) on one line, and it says these two lists belong together. Returning a list of two lists would allow appending a third, which means nothing here. Reach for a tuple when the size is part of the meaning.
You need the same index twice: once as the end of a slice and once as its start. Do not add or subtract anything from it.
items[:index] and items[index:] are the two pieces. Both already return new lists, both already clamp at the ends, and both already understand negative indices — there is nothing to guard.
Return the two slices as a tuple. Resist adding a check for an index larger than the list or smaller than zero: every guard you could write here is already implemented by the slice, and writing your own is how the two pieces stop fitting together.
def split_at(items, index): return (items[:____], items[____:])
def split_at(items, index):
# The same index as an end and as a start: half-open slicing means the
# two pieces meet exactly, for any index at all.
return (items[:index], items[index:])
There is a version of this with len(), a max(), a min() and a loop, and it is longer, slower, and wrong for at least one of the six edge cases above on the first attempt. Recognising when the language already has the invariant you need — here, that consecutive half-open slices partition a sequence — is a large part of writing code that does not need debugging.
Each slice builds its own list, so the caller can append to either piece without affecting the other or the original. They are shallow copies — the items are shared — which matters only if the items themselves are mutable and someone changes one in place.
Slicing is defined by the sequence, so split_at('hello', 2) gives ('he', 'llo') and a tuple gives tuples. That is either a nice generalisation or a surprise, depending on whether the caller was promised lists. If you need lists out regardless of what went in, list() each piece and say so in the contract.
If the input is an iterator rather than a sequence, this does not apply at all — you cannot slice a generator, and taking the first n items means itertools.islice, after which the 'rest' is whatever is left of the same iterator and cannot be rewound. When the split point is a condition rather than a position, you want a different function: itertools.takewhile and dropwhile, or a partition by predicate. And when you are splitting into many pieces rather than two, that is chunking.
- Write split_where(items, predicate), cutting at the first item that matches, and see how much less obvious the guarantee becomes.
- Use it to build a move(items, source, destination) that relocates one item, and check what happens when the destination is after the source.
- Check the joining promise directly: for every index from -10 to 10, assert that the two pieces concatenate back to the original.