The three shallow copies
nums[:] list(nums) nums.copy()
All equivalent. dict.copy() and set.copy() behave the same way, and dict(d) is the dict equivalent of list(l).
When shallow is enough
If everything inside is immutable — numbers, strings, tuples of those — a shallow copy is a complete copy in every way that matters. There is nothing shared that can change, so the distinction disappears.
That covers most everyday copying, which is why [:] is so common and why the problem stays hidden until the day your data has a list inside a list.
When you need deep
import copy
deep = copy.deepcopy(original)
deepcopy walks the whole structure and rebuilds every mutable object it finds. Nested config dictionaries, lists of records, anything parsed from JSON — these are the cases.
It is slower, and for large structures noticeably so. It also handles the hard cases correctly: shared references stay shared in the copy, and cycles do not cause infinite recursion. Writing your own recursive copy usually gets both of those wrong.
The dict version of the trap
config = {"limits": {"max": 10}}
shallow = config.copy()
shallow["limits"]["max"] = 999
The original now reads 999 too. This is the same rule and it bites harder with configuration, because the nesting is the point of the structure.
The rule
Ask what is inside. Flat and immutable: use a slice or .copy(). Nested and mutable: use deepcopy, or restructure so you are not copying a mutable tree at all.