Question: I have a list of strings that I want to sort by two custom key functions in Python 3.6. Comparing the multi-sort approach (sorting by the lesser key then by the major key) to the multi-key approach (taking the key as the tuple (major_key, lesser_key)), I could see the latter being more than 2x slower than the former, which was a surprise as I thought they are equivalent. I would like to understand why this is so. import random from time import time largest = 1000000 length = 10000000 start = time() lst = [str(x) for x in random.choices(range(largest), k=length)] t0 = time() - start start = time() tmp = sorted(lst, key=lambda x: x[::2]) l1 = sorted(tmp, key=lambda x: ''.join(sorted(x))) t1 = time() - start start = time() l2 = sorted(lst, key=lambda x: (''.join(sorted(x)), x[::2])) t2 = time() - start print(f'prepare={t0} multisort={t1} multikey={t2} slowdown={t2/t1}') assert l1 == l2 

Answer: Here's a third way to time: start = time() l3 = sorted(lst, key=lambda x: (''.join(sorted(x)) + "/" + x[::2])) t3 = time() - start  and expanding the last line to assert l1 == l2 == l3  This uses a single string as the key, but combining the two string keys you view as as being the "primary" and "secondary" keys. Note that: >>> chr(ord("0") - 1) '/'  That's why the two keys can be combined - they're separated by an ASCII character that compares "less than" any ASCII digit (of course this is wholly specific to the precise kind of keys you're using). This is typically a little faster than multisort() for me, using the precise program you posted.  prepare=3.628943920135498 multisort=15.646344423294067 multikey=34.255955934524536 slowdown=2.1893903782103075 onekey=15.11461067199707  I believe the primary reason "why" is briefly explained at the end of a modern CPython distribution's Objects/listsort.txt:  As noted above, even the simplest Python comparison triggers a large pile of C-level pointer dereferences, conditionals, and function calls. This can be partially mitigated by pre-scanning the data to determine whether the data is homogeneous with respect to type. If so, it is sometimes possible to substitute faster type-specific comparisons for the slower, generic PyObject_RichCompareBool.  When there's a single string used as the key, this pre-sorting scan deduces that all the keys in the list are in fact strings, so all the runtime expense of figuring out which comparison function to call can be skipped: sorting can always call the string-specific comparison function instead of the all-purpose (and significantly more expensive) PyObject_RichCompareBool. multisort() also benefits from that optimization. But multikey() doesn't, much. The pre-sorting scan sees that all the keys are tuples, but the tuple comparison function itself can't assume anything about the types of the tuple's elements: it has to resort to PyObject_RichCompareBool every time it's invoked. (Note: as touched on in comments, it's not really quite that simple: some optimization is still done exploiting that the keys are all tuples, but it doesn't always pay off, and at best is less effective - and see the next section for clearer evidence of that.) Focus There's a whole lot going on in the test case, which leads to needing ever-greater effort to explain ever-smaller distinctions. So to look at the effects of the type homogeneity optimizations, let's simplify things a lot: no key function at all. Like so: from random import random, seed from time import time length = 10000000 seed(1234567891) xs = [random() for _ in range(length)] ys = xs[:] start = time() ys.sort() e1 = time() - start ys = [(x,) for x in xs] start = time() ys.sort() e2 = time() - start ys = [[x] for x in xs] start = time() ys.sort() e3 = time() - start print(e1, e2, e3)  Here's typical output on my box:  3.1991195678710938 12.756590843200684 26.31903386116028  So it's by far fastest to sort floats directly. It's already very damaging to stick the floats in 1-tuples, but the optimization still gives highly significant benefit: it takes over twice as long again to stick the floats in singleton lists. In that last case (and only in that last case), PyObject_RichCompareBool is always called.