The world’s leading publication for data science, AI, and ML professionals.

Python Tuple, The Whole Truth and Only Truth: Let’s Dig Deep

Learn the intricacies of tuples

PYTHON PROGRAMMING

.

Tuples' immutability can be confusing and headache-inducing. Photo by Aarón Blanco Tejedor on Unsplash
Tuples’ immutability can be confusing and headache-inducing. Photo by Aarón Blanco Tejedor on Unsplash

In the previous article, we discussed the basics of tuples:

Python Tuple, the Whole Truth, and Only the Truth: Hello, Tuple!

I showed you what a tuple is, what methods it offers, and most importantly, we discussed tuples immutability. But there is far more into tuples than that, and this articles offers continuation of the previous one. You will learn here the following aspects of the tuple type:

  • The intricacies of the tuple: the effect of immutability on copying tuples, and tuple type hinting.
  • Inheriting from tuple.
  • Tuple performance: execution time and memory.
  • The advantages of tuples over lists (?): clarity, performance, and tuples as dictionary keys.
  • Tuple comprehensions (?)
  • Named tuples

The intricacies of the tuple

Likely the most important intricacy of the tuple is its immutability. But since it creates the essence of this type, even beginners should know how this immutability works and what it means in both theory and practice; thus, we’ve discussed it in the above-mentioned previous article. Here, we will discuss other important intricacies of tuples.

The effect of immutability on copying tuples

This will be fun!

A theorist would likely scream at me that there is only one immutability of tuples, the one we discussed in the previous article. Well, that’s true, but… but Python itself makes a distinction between two different types of immutability! And Python must make this distinction. This is because only a truly immutable object is hashable. In the below code, you will see that the first tuple is hashable but the second one is not:

>>> hash((1,2))
-3550055125485641917
>>> hash((1,[2]))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

Whether or not an object is hashable affects various things – and this is why Python differentiates hashable and non-hashable tuples; the former are what I call truly immutable tuples. I will show you how Python treats both in how copying of tuples works, and in using tuples as dictionary keys.

First, let’s see how it works in tuple copying. For this. let us create a fully immutable tuple and copy it using all the available methods:

>>> import copy
>>> a = (1, 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a)     # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy

Since a is a fully immutable tuple, the original tuple (a) and all its copies should point to the very same object:

>>> a is b is c is d is e is f
True

As expected – and as should be the case for a truly immutable type – all these names point to the very same object; their ids are the same. This is what I call true or full immutability.

Now let’s do the same with a tuple of the second type; that is, a tuple with one or more mutable elements:

>>> import copy
>>> a = ([1], 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a)     # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy

The copies from b to e are shallow, so they will refer to the same object as the original name:

>>> a is b is c is d is e
True

This is why we have deep copying. A deep copy should cover all the objects, including those nested inside. And since we have a mutable object inside the a tuple, then unlike before, the deep copy f this time will not point to the same object:

>>> a is f
False

The first element (at index 0) of the tuple is [1], so it’s mutable. When we created the shallow copies of a, the first elements of the tuples a to e pointed to the same list:

>>> a[0] is b[0] is c[0] is d[0] is e[0]
True

but creating a deep copy meant creating a new list:

>>> a[0] is f[0]
False

Now let’s see how these two types of immutability work differ in terms of using tuples as dictionary keys:

>>> d = {}
>>> d[(1, 2)] = 3
>>> d[(1, [2])] = 4
Traceback (most recent call last):
    ...
TypeError: unhashable type: 'list'

So, if you want to use a tuple as a dictionary key, it must be hashable – so it must be truly immutable.

So, if anyone tells you that there is only one type of immutability of Python tuples, you will know that’s not entirely true – as there are two sorts of tuples in terms of immutability:

  • fully immutable tuples, containing only immutable elements; this is immutability in terms of both references and values;
  • immutable tuples in terms of references but not values, that is, tuples containing mutable element(s).

Failing to distinguish them would disable you to understand how copying of tuples works.

Tuple type hinting

Type hints have been becoming more and more important in Python. Some say that there’s no modern Python code without type hints. As I wrote what I think in another article, I will not repeat myself here. If you’re interested, feel invited to read it:

Python’s Type Hinting: Friend, Foe, or Just a Headache?

Here, let’s shortly discuss how to deal with type hints for tuples. I will show the modern version to type hinting tuples, meaning Python 3.11. As type hinting has been dynamically changing, however, be aware that not everything worked the same way in older Python versions.

As of Python 3.9, things got simpler, as you can use built-in tuple type with fields indicated in square brackets []. Below are several examples of what you can do.

tuple[int, ...], tuple[str, ...] and the like This means the object is a tuple of int / str / and the like elements, of any length. The ellipsis, ..., informs that the tuple can have any length; there is no way to fix it.

tuple[int | float, ...]Like above, but the tuple may contain both int and float items.

tuple[int, int]Unlike above, this tuple is a record of two items, both being integers.

tuple[str, int|float]Again, a record of two items, the first being a string and the second an integer or a floating-point number.

tuple[str, str, tuple[int, float]]A record with three items, the first two being strings and the third one being a two-element tuple of an integer and a floating-point number.

tuple[Population, Area, Coordinates] This is a specific record, one that contains three elements of specific types. These types, Population, Area, Coordinates are either named tuples or data types defined earlier, or type aliases. As I explained in the above-mentioned article, using such type aliases can be much more readable than using the built-in types such as int, float, and the like.

These were just several examples, but I hope they will help you see what you can do with type hinting for tuples. I have only mentioned named tuples, as I will discuss this type in another section below. Do remember, however, that named tuples are of much help also in the context of type hinting, as thanks to a named tuple you can get a custom type alias that is also a data container – a powerful combination.

Inheriting from tuple

You can inherit from list, though sometimes it’s better to inherit from collections.UserList. So, maybe we can do the same with the tuple? Can we inherit from the tuple class?

Basically, forget the idea of creating a tuple-like general type. The tuple does not have its own .__init__() method, so you cannot do what you can when inheriting from the list, that is, you cannot call super().__init__(). And without that, you’re left with almost nothing as the tuple class inherits object.__init__() instead.

Nonetheless, this does not mean you cannot inherit from tuple at all. You can, but not to create a general type, but a specific one. Do you remember the City class? We can do something similar with a tuple – but be aware that this will be no fun.

>>> class City(tuple):
...    def __new__(self, lat, long, population, area):
...        return tuple.__new__(City, (lat, long, population, area))

We have a tuple-like City class:

>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2)
>>> Warsaw
(52.2297, 21.0122, 1765000, 517.2)
>>> Warsaw[0]
52.2297

This class takes exactly four arguments, not fewer and not more:

>>> Warsaw = City(52.2297, 21.0122, 1_765_000)
Traceback (most recent call last):
    ...
TypeError: __new__() missing 1 required positional argument: 'area'
>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2, 50)
Traceback (most recent call last):
    ...
TypeError: __new__() takes 5 positional arguments but 6 were given

Note that in the current version, we can use argument names but do not have to, as they are positional:

>>> Warsaw_names = City(
...     lat=52.2297,
...     long=21.0122,
...     population=1_765_000,
...     area=517.2
... )
>>> Warsaw == Warsaw_names
True

But we cannot access the values by names:

>>> Warsaw.area
Traceback (most recent call last):
    ...
AttributeError: 'City' object has no attribute 'area'

We can change that in two ways. One is by using a named tuple from the collections or typing module; we will discuss them soon. But we can achieve the same effect using our City class, thank to the operator module:

>>> import operator
>>> City.lat = property(operator.itemgetter(0))
>>> City.long = property(operator.itemgetter(1))

And now we can access lat and long attributes by name:

>>> Warsaw.lat
52.2297
>>> Warsaw.long
21.0122

However, since we did the above only for lat and long, we will not be able to access population and area by name:

>>> Warsaw.area
Traceback (most recent call last):
    ...
AttributeError: 'City' object has no attribute 'area'

We can of course change that:

>>> City.population = property(operator.itemgetter(2))
>>> City.area = property(operator.itemgetter(3))
>>> Warsaw.population
1765000
>>> Warsaw.area
517.2

I’ve never done anything like this, however. If you want to have such a functionality, you should definitely use a named tuple instead.

Tuple performance

Execution time

To benchmark various operations using tuples and, for comparison, lists, I used the script presented in Appendix close to the end of the article. You will also find there the results of running the code. I present the code not only for the record, but to enable you to extend the experiment.

Overall, the list was always faster, irrespective of its size and the operation being performed. I’ve often heard that one of the reasons behind creating tuples was their smaller memory consumption. Our little experiment is far from confirming this idea. While indeed sometimes tuples used a little less memory, usually they used a little more. Hence I conducted the experiment for really long lists and tuples, of 5 mln and 10 mln integer items. And again, lists usually consumed less memory…

So, where is this small memory consumption of tuples? Perhaps it’s related to how much disk space a tuple and the corresponding list take? Let’s check:

>>> from pympler.asizeof import asizeof
>>> for n in (3, 10, 100, 1000, 1_000_000, 5_000_000, 10_000_000):
...     print(
...         f"tuple, n of {n: 9}: {asizeof(tuple(range(n))):10d}"
...         "n"
...         f" list, n of {n: 9}: {asizeof(list(range(n))):10d}"
...         "n"
...         f"{'-'*33}"
...         )
tuple, n of         3:        152
 list, n of         3:        168
---------------------------------
tuple, n of        10:        432
 list, n of        10:        448
---------------------------------
tuple, n of       100:       4032
 list, n of       100:       4048
---------------------------------
tuple, n of      1000:      40032
 list, n of      1000:      40048
---------------------------------
tuple, n of   1000000:   40000032
 list, n of   1000000:   40000048
---------------------------------
tuple, n of   5000000:  200000032
 list, n of   5000000:  200000048
---------------------------------
tuple, n of  10000000:  400000032
 list, n of  10000000:  400000048
---------------------------------

Only in the case of small tuples and their corresponding lists, the difference in memory use is noticeable – like, for instance, 152 against 168. But I think you’ll agree with me that 400_000_032 is not really that much smaller than 400_000_048, won’t you?

There’s one more thing I observed in my past experiments (code not presented). Tuple literals are treated in an exceptional way by the Python compiler, as it keeps them in the static memory – so they are created at compile time. Neither lists nor tuples created in any other way can be kept in static memory – they always use dynamic memory, which means they are created at run time. This topic is complex enough to deserve a separate article, so let’s stop here.

I’ll leave you here with these benchmarks. If you want to extend them, go ahead. If you learn something new and unexpected, please share this in the comments.

What I have learned is that tuples should almost never be used only because of their performance. But indeed, tuples can be an interesting choice if we need a simple type to store really small records, like consisting of two or three elements. If field names would help, however, and for more fields, I’d rather use something else, a named tuple being one of the choices and a dataclasses.dataclass another.

A list and a tuple. Image by author.
A list and a tuple. Image by author.

Advantages of tuples over lists (?)

In Fluent Python, L. Ramalho mentions two advantages of a tuple over a list: clarity and performance. Honestly, I cannot find any other advantage, but these two can be enough. So, let’s discuss them one by one and decide if they indeed make tuples better than lists, at least in some aspects.

Clarity

As L. Ramalho writes, when you’re using a tuple, you know its length will never change – and this increases the clarity of code. We have already discussed what can happen with a tuple’s length. Indeed, clarity due to immutability is a great thing, and we do know that the length of any tuple will never change, but…

As L. Ramalho warns himself, a tuple with mutable items can be a source of bugs that are difficult to find. Do you remember what I mentioned above in relation to in-place operations? On the one hand, we may be sure that a tuple, say x, will never change its length. It’s a valuable piece of information in terms of clarity, I agree. However, when we perform in-place operation(s) on x, this tuple will stop being the same tuple, even though it will remain a tuple named x – but, let me repeat, a different tuple named x.

Thus, we should revise the above clarity advantage as follows:

  • We can be sure that a tuple of a particular id will never change its length.

Or:

  • We can be sure that if we define a tuple of a particular length, it will not change its length, but we should remember that if we use any in-place operation, then this tuple is not the same tuple we meant before.

Sounds a little crazy? I fully agree: this is crazy. For me, this is no clarity; this is the opposite of clarity. Does anyone think that way? Imagine you have a function in which you define a tuple x. You then perform in-place concatenation, e.g., x += y, so it looks as though y remained untouched but x changed. We know it’s not true – as this original x does not exist anymore and we have a brand new x – but this is what it looks like, especially because we still have a tuple x whose first elements are the very same ones that constituted the original x tuple.

Sure, I know all this makes sense from a Python point of view. But when I’m coding, I do not want my thoughts to be occupied that way. For code to be clear, I prefer it to be clear without the necessity of making such assumptions. And this is the very reason why for me tuples do not mean clarity; they mean less clarity than I see in lists.

This is not all in the context of the tuple’s clarity. In terms of code, there is one thing I particularly like in lists but do not like in tuples. Square brackets [] used to create lists allow them to stand out in the code, as there is no other container that would use square brackets. Look at dictionaries: they use curly brackets {}, and these can be used by sets, too. Tuples use round parentheses (), and these are used not only in generator expressions but in many various places in code, as Python code uses round parentheses for many different purposes. Therefore, I like how lists stand out in code – and I don’t like how tuples do not.

Performance

L. Ramalho, writes that a tuple uses less memory than the corresponding list, and Python can do the same optimizations for both. We have already analyzed memory performance as we know that it’s not always the case – and that the disk memory a tuple uses is indeed smaller than that the corresponding list uses, but the difference can be negligible.

This knowledge, combined with the better performance of lists in terms of execution time, makes me think that performance does not make tuples a better choice. In terms of performance in terms of execution time, lists are better. In terms of memory usage, tuples can be better indeed – but these days, with the modern computers, differences are really small. Besides, when I need a truly memory-efficient container to collect a lot of data, I’d choose neither list nor tuple – but a generator.

Another thing: tuples as dictionary keys

In addition to these two aspects, there is a third one that’s worth consideration, one we have already mentioned – you cannot use lists as keys in dictionaries, but you can use tuples. Or rather, you can use truly immutable (that is, hashable) tuples. The reason is the former’s mutability and the latter’s immutability.

Unlike the previous two, this advantage can be significant in particular situations, even if rather rare ones.

Tuple comprehensions (?)

If you hope to learn from this section that there are tuple comprehensions in Python, or if you hope to learn something amazing that will blew minds of your fellow Pythonistas – I am so sorry! I did not want to create false hopes. No tuple comprehensions today; no mind-blowing syntax.

You may remember that in my article on Python comprehensions, I did not mention tuple comprehensions:

A Guide to Python Comprehensions

This is because there are no tuple comprehensions. But as I do not want to leave you with nothing, I do have a consolation gift for you. I’ll show you some substitutes for tuple comprehensions.

First of all, do remember that a generator expression is not a tuple comprehension. I think many Python beginners make a mistake of confusing the two. I specifically remember seeing my first generator expression after learning list comprehensions. My first thought was, "Yup, here it is. A tuple comprehension." I quickly learned that while the first from these two was indeed a list comprehension, the second was not a tuple comprehension:

>>> listcomp = [i**2 for i in range(7)] # a list comprehension
>>> genexp = (i**2 for i in range(7))   # NOT a tuple comprehension

I spent some time – if not wasted it – only to learn that there are list comprehensions, and set comprehensions, and dict comprehensions, and generator expressions – but no tuple comprehensions. Don’t repeat my mistake. Don’t spend hours on looking for tuple comprehensions. They don’t exist in Python.

But here it is, my consolation gift for you – two substitutes for tuple comprehensions.

Substitute 1: tuple() + genexp

>>> tuple(i**2 for i in range(7))
(0, 1, 4, 9, 16, 25, 36)

Have you noticed that you do not need to create a list comprehension first and the tuple then? Indeed, here we create a generator expression and use the tuple() class to it. This, of course, gives us a tuple.

Substitute 2: genexp + generator unpacking

>>> *(i**2 for i in range(7)),
(0, 1, 4, 9, 16, 25, 36)

A nice hack, isn’t it? It uses the extended unpacking of iterables, which returns a tuple. You can use it for any iterable, and since a generator is one, it works! Let’s check if it works also for a list:

>>> x = [i**2 for i in range(7)]
>>> *x,
(0, 1, 4, 9, 16, 25, 36)

You can do the same without assigning to x:

>>> *[i**2 for i in range(7)],
(0, 1, 4, 9, 16, 25, 36)

It will work for any iterable – but don’t forget the comma at the line end; without it, the trick will not work:

>>> *[i**2 for i in range(7)]
  File "<stdin>", line 1
SyntaxError: can't use starred expression here

Let’s check for sets:

>>> x = {i**2 for i in range(7)}
>>> *x,
(0, 1, 4, 9, 16, 25, 36)

It works! And note that generally, unpacking provides a tuple. This is why the extended iterable unpacking looks a little bit like a tuple comprehension. Although it does look like a nice little hack, it’s not: it’s one of the tools that Python offers, although it’s an edge case indeed.

But I would not use substitute 2. I’d definitely go for substitute 1, which uses tuple(). Most of us love tricks like the second substitute, but they are seldom clear – and substitute 2, unlike substitute 1, is far from being clear. Nevertheless, any Pythonista will see what’s going on in substitute 1, even if they do not see that there’s a generator expression hidden in an in-between step.

Named tuples

Tuples are unnamed – but this does not mean there are no named tuples in Python. On the contrary, there are – and, unsurprisingly, they are called…named tuples.

You have two possibilities to use named tuples: collections.namedtuple and typing.NamedTuple. Named tuples are what their named suggest: tuples whose elements (called fields) have names. You can see the former in action in Appendix, in the benchmarking script.

Personally, I consider them extremely helpful in many various situations. They do not offer any improvement in performance; they can even decrease it. But when it comes to clarity, they can be much clearer, both to the developer and to the code’s user.

Thus, although I often go for a regular tuple, sometimes I decide to choose a named tuple – and this is exactly because of its clarity.

Named tuples offer so rich possibilities that they deserve their own article. Therefore, that’s all I’m going to tell you about them here – but I plan to write an article dedicated to this powerful type.

Word "tuple" in various languages. Image by author.
Word "tuple" in various languages. Image by author.

Conclusion

This article, along with the previous one, aimed to provide you with deep information about tuples, their use cases, pros and cons, and intricacies. Although tuples are used quite often, they are not that well known among developers, particularly those with shorter experience in Python development. That’s why I wanted to collect rich information about this interesting type in one place – and I hope you’ve learned something from reading it, maybe even as much as I learned myself from writing it.

To be honest, when starting to write about tuples, I thought that I’d find more advantages of them. I’ve been using them from the first day I started using Python. Although I used lists far more often, I somehow liked tuples, even though I did not know too much about them – so some of the information I included in this article was new to me.

After writing this article, however, I am not that great a fan of tuples anymore. I still consider them a valuable type for small records, though quite often their extension – named tuples – or data classes seem a better approach. What’s more, tuples do not seem to be too effective. They are slower than lists, and use only a little less memory. So, why should I use them?

Maybe because of their immutability? Maybe. If you like functional Programming, which is based upon the concept of immutability, you will definitely prefer tuples over lists. I used this argument not once and not twice to convince myself that I should prefer a tuple over a list in this or another situation.

But the immutability that the tuple offers is, as we discussed, not that clear. Imagine x, a tuple of items of immutable types. We know this tuple is trule immutable, right? If so, I do not like the following code, which is fully correct in Python:

>>> x = (1, 2, 'Zulu Minster', )
>>> y = (4, 4, )
>>> x += y
>>> x
(1, 2, 'Zulu Minster', 4, 4)
>>> x *= 2
>>> x
(1, 2, 'Zulu Minster', 4, 4, 1, 2, 'Zulu Minster', 4, 4)

I know this is correct Python, and I know this is even Pythonic code – but I don’t like it. I don’t like that I can do something like this with Python Tuples. It just does not have the vibe of the tuple’s immutability. The way I see it, if you have an immutable type, you should be able to copy it, you should be able to concatenate two instances, and the like – but you should not be able to assign a new tuple to an old name using an in-place operation. You want this name to be the same? Your choice. So, I am fine with this:

>>> x = x + y

as it means assigning x + y to x, which basically means overwriting this name. If you choose to overwrite the previous value of x, it’s your choice. But in-place operations, at least in my eyes, do not have the feeling of immutability. I’d prefer to not be able to do this in Python.

If not immutability, then maybe something else should convince me to use tuples more often? But what? Performance? Tuples’ performance is poor, so this does not convince me. In terms of execution time, there is no discussion; they are definitely slower than the corresponding lists. You may say that in terms of memory. Indeed, they do take less disk space, but the difference is subtle, and for long containers – totally negligible. RAM memory use? This argument also turned out to not be too successful, because generally, lists turned out to be as efficient as tuples – and sometimes even more efficient. And if we have a huge collection, a generator will do better in terms of memory.

Despite all that, tuples do have their place in Python. They are very frequently used to return two or three items from a function or method – so, as small unnamed records. They are used as the output of iterable unpacking. And they constitute the base of named tuples – collections.namedtuple and typing.NamedTuple – the tuple’s powerful siblings that can be used as records with named fields.

All in all, I do not like tuples as much as I did before writing this article. I treated them as an important Python type; now it’s not as important in my eyes as it was – but I accept their various use cases in Python, and I even like some of them.

Am I unfair for tuples? Maybe. If you think so, please let me know in the comments. I always enjoy fruitful discussions with my readers.


Thank you for reading. If you enjoyed this article, you may also enjoy other articles I wrote; you will see them here. If you want to join Medium, please use my referral link below:

Join Medium with my referral link – Marcin Kozak

Resources

A Guide to Python Comprehensions

PEP 3132 – Extended Iterable Unpacking

Fluent Python, 2nd Edition

Appendix

In this Appendix, you will find the script I used for benchmarking tuples against lists. I used the perftester package, about which you can read in this article:

Benchmarking Python Functions the Easy Way: perftester

This is the code:

import perftester

from collections import namedtuple
from typing import Callable, Optional
Length = int

TimeBenchmarks = namedtuple("TimeBenchmarks", "tuple list better")
MemoryBenchmarks = namedtuple("MemoryBenchmarks", "tuple list better")
Benchmarks = namedtuple("Benchmarks", "time memory")

def benchmark(func_tuple, func_list: Callable,
              number: Optional[int] = None) -> Benchmarks:
    # time
    t_tuple = perftester.time_benchmark(func_tuple, Number=number)
    t_list = perftester.time_benchmark(func_list, Number=number)
    better = "tuple" if t_tuple["min"] < t_list["min"] else "list"
    time = TimeBenchmarks(t_tuple["min"], t_list["min"], better)

    # memory
    m_tuple = perftester.memory_usage_benchmark(func_tuple)
    m_list = perftester.memory_usage_benchmark(func_list)
    better = "tuple" if m_tuple["max"] < m_list["max"] else "list"
    memory = MemoryBenchmarks(m_tuple["max"], m_list["max"], better)

    return Benchmarks(time, memory)

def comprehension(n: Length) -> Benchmarks:
    """List comprehension vs tuple comprehension.

    Here, we're benchmarking two operations:
      * creating a container
      * looping over it, using a for loop; nothing is done in the loop.
    """
    def with_tuple(n: Length):
        x = tuple(i**2 for i in range(n))
        for _ in x:
            pass

    def with_list(n: Length):
        x = [i**2 for i in range(n)]
        for _ in x:
            pass
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)

def empty_container() -> Benchmarks:
    """List vs tuple benchmark: creating an empty container."""
    return benchmark(lambda: tuple(), lambda: [], number=100_000)

def short_literal() -> Benchmarks:
    """List vs tuple benchmark: tuple literal."""
    return benchmark(lambda: (1, 2, 3), lambda: [1, 2, 3], number=100_000)

def long_literal() -> Benchmarks:
    """List vs tuple benchmark: tuple literal."""
    return benchmark(
        lambda: (1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,),
        lambda: [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,],
        number=100_000)

def func_with_range(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(n: Length):
        return tuple(range(n)) 

    def with_list(n: Length):
        return list(range(n))
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)

def concatenation(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(x: tuple):
        x += x
        return x

    def with_list(y: list):
        y += y
        return y
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(tuple(range(n))),
                     lambda: with_list(list(range(n))),
                     number)

def repeated_concatenation(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(x: tuple):
        x *= 5
        return x

    def with_list(y: list):
        y *= 5
        return y
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(tuple(range(n))),
                     lambda: with_list(list(range(n))), number)

if __name__ == "__main__":
    n_set = (3, 10, 20, 50, 100, 10_000, 1_000_000)
    functions = (
        comprehension,
        empty_container,
        short_literal,
        long_literal,
        func_with_range,
        concatenation,
        repeated_concatenation,
        )
    functions_with_n = (
        comprehension,
        func_with_range,
        concatenation,
        repeated_concatenation,
    )

    results = {}
    for func in functions:
        name = func.__name__
        print(name)
        if func in functions_with_n:
            results[name] = {}
            for n in n_set:
                results[name][n] = func(n)
        else:
            results[name] = func()
    perftester.pp(results)

And here are the results:

{'comprehension': {3: Benchmarks(time=TimeBenchmarks(tuple=9.549e-07, list=8.086e-07, better='list'), memory=MemoryBenchmarks(tuple=15.62, list=15.63, better='tuple')),
                   10: Benchmarks(time=TimeBenchmarks(tuple=2.09e-06, list=1.94e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.64, better='list')),
                   20: Benchmarks(time=TimeBenchmarks(tuple=4.428e-06, list=4.085e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.65, better='tuple')),
                   50: Benchmarks(time=TimeBenchmarks(tuple=1.056e-05, list=9.694e-06, better='list'), memory=MemoryBenchmarks(tuple=15.69, list=15.69, better='list')),
                   100: Benchmarks(time=TimeBenchmarks(tuple=2.032e-05, list=1.968e-05, better='list'), memory=MemoryBenchmarks(tuple=15.7, list=15.7, better='list')),
                   10000: Benchmarks(time=TimeBenchmarks(tuple=0.002413, list=0.002266, better='list'), memory=MemoryBenchmarks(tuple=15.96, list=16.04, better='tuple')),
                   1000000: Benchmarks(time=TimeBenchmarks(tuple=0.2522, list=0.2011, better='list'), memory=MemoryBenchmarks(tuple=54.89, list=54.78, better='list'))},
 'concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.38e-07, list=3.527e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   10: Benchmarks(time=TimeBenchmarks(tuple=4.89e-07, list=4.113e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   20: Benchmarks(time=TimeBenchmarks(tuple=5.04e-07, list=4.368e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   50: Benchmarks(time=TimeBenchmarks(tuple=7.542e-07, list=6.22e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   100: Benchmarks(time=TimeBenchmarks(tuple=1.133e-06, list=9.005e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001473, list=0.000126, better='list'), memory=MemoryBenchmarks(tuple=31.7, list=31.7, better='list')),
                   1000000: Benchmarks(time=TimeBenchmarks(tuple=0.04862, list=0.04247, better='list'), memory=MemoryBenchmarks(tuple=123.5, list=125.4, better='tuple'))},
 'empty_container': Benchmarks(time=TimeBenchmarks(tuple=1.285e-07, list=1.107e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
 'func_with_range': {3: Benchmarks(time=TimeBenchmarks(tuple=3.002e-07, list=3.128e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
                     10: Benchmarks(time=TimeBenchmarks(tuple=4.112e-07, list=3.861e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
                     20: Benchmarks(time=TimeBenchmarks(tuple=4.228e-07, list=4.104e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.93, better='list')),
                     50: Benchmarks(time=TimeBenchmarks(tuple=5.761e-07, list=5.068e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.94, better='tuple')),
                     100: Benchmarks(time=TimeBenchmarks(tuple=7.794e-07, list=6.825e-07, better='list'), memory=MemoryBenchmarks(tuple=23.94, list=23.94, better='list')),
                     10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001536, list=0.000159, better='tuple'), memory=MemoryBenchmarks(tuple=24.67, list=24.67, better='list')),
                     1000000: Benchmarks(time=TimeBenchmarks(tuple=0.03574, list=0.03539, better='list'), memory=MemoryBenchmarks(tuple=91.7, list=88.45, better='list'))},
 'long_literal': Benchmarks(time=TimeBenchmarks(tuple=1.081e-07, list=8.712e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
 'repeated_concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.734e-07, list=3.836e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            10: Benchmarks(time=TimeBenchmarks(tuple=4.594e-07, list=4.388e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            20: Benchmarks(time=TimeBenchmarks(tuple=5.975e-07, list=5.578e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            50: Benchmarks(time=TimeBenchmarks(tuple=9.951e-07, list=8.459e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            100: Benchmarks(time=TimeBenchmarks(tuple=1.654e-06, list=1.297e-06, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            10000: Benchmarks(time=TimeBenchmarks(tuple=0.0002266, list=0.0001945, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            1000000: Benchmarks(time=TimeBenchmarks(tuple=0.09504, list=0.08721, better='list'), memory=MemoryBenchmarks(tuple=169.4, list=169.4, better='tuple'))},
 'short_literal': Benchmarks(time=TimeBenchmarks(tuple=1.048e-07, list=1.403e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list'))}

I decided to run memory-usage benchmarks for much bigger n, that is, of 5 and 10 million. I will not present the code here, and if you have some time to spare, you may consider it a nice exercise to write it, based on the above script.

If you just want to see the code, however, you will find it here. Note that I could make the code better, as I could join the code for the two experiments. I decided not to do that, in order to keep the two scripts rather simple.

Here are the results:

{'comprehension': {5000000: MemoryBenchmarks(tuple=208.8, list=208.8, better='list'),
                   10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='tuple')},
 'concatenation': {5000000: MemoryBenchmarks(tuple=285.4, list=247.2, better='list'),
                   10000000: MemoryBenchmarks(tuple=554.8, list=478.5, better='list')},
 'func_with_range': {5000000: MemoryBenchmarks(tuple=400.4, list=396.4, better='list'),
                     10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='list')},
 'repeated_concatenation': {5000000: MemoryBenchmarks(tuple=399.8, list=361.7, better='list'),
                            10000000: MemoryBenchmarks(tuple=783.7, list=707.4, better='list')}}

As you see, for the operations we study, tuples take either the same or more memory – sometimes even significantly more (compare, for instance, 554.8 vs 478.5 or 783.7 vs 707.4).


Related Articles