Run it
The divergence at the inner level, the leak demonstrated by one append. And the cycle deepcopy survives.
Where the two diverge, and the one append that proves it.
Every other spelling of copy is the shallow one, for dicts as well as lists.
And the two things a naive recursive copy would get wrong.
What "shallow" means exactly
A shallow copy allocates a new container and fills it with the same references the first holds. So the outer objects are free — appending to the copy does not lengthen the first — and every element is shared.
For flat data that distinction never appears. A list of integers or strings copies shallowly and behaves exactly as you would want because the shared elements are immutable and nobody can change them. The bug needs two ingredients: nesting, and mutation.
Everything is shallow unless you asked
These are all the same operation: b = a[:], b = list(a), b = a.copy(), b = copy.copy(a), b = [*a], and dict(d) for dictionaries. One level.
copy.deepcopy is the only one in that list that recurses. It also handles the two things a naive recursion would get wrong: cycles, via a memo of objects already copied. So a list containing itself does not hang; and identity sharing. So if the same object appears twice in the source it appears twice as the same object in the result rather than being duplicated.
What deepcopy costs, and when to avoid it
It walks the entire object graph and rebuilds it, so it is proportional to the total size rather than the top-level length. And it is slow enough to notice in a loop. It also copies things you may not want copied: a nested object holding a database connection, an open file or a lock either fails or produces a duplicate that is meaningless.
Classes control this. __deepcopy__ and __copy__ let a type define what copying means, and copy.deepcopy falls back to the pickle protocol (__reduce_ex__) — which is why objects that cannot be pickled often cannot be deep-copied either, for the same reason.
The cheapest fix is often neither: restructure so the nested mutable is not shared in the first place, or use immutable values so copying is unneeded.
The follow-up: the nested-list multiplication bug
"What does [[0] * 3] * 3 give you?" A list of three references to one inner list. So setting grid[0][0] sets the first cell of every row. It is the same fact as this page — the outer sequence was copied and the inner one was shared — arriving through multiplication rather than through a copy call. The fix is a comprehension, which evaluates [0] * 3 once per row.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
Tuples are only as immutable as their contents. ([1, 2], 3) cannot be reassigned and its inner list can still be appended to. So a tuple is not a defence against the problem on this page.
A dict comprehension over a dict is shallow too, as is {**d}. Every spelling of "make me a new dict from this one" shares the values.
copy.deepcopy on a large structure can be slower than recomputing it. Worth saying out loud because it shows you are costing the fix rather than reaching for the safest-sounding call.