Run it
The three states, the one-shot exhaustion, and the memory difference at a million items - printed rather than asserted.
The three states, driven by hand.
One-shot is not a warning, it is silence - the second consumer sees an empty sequence and nothing complains.
And what the laziness actually buys, in bytes.
A generator is a paused frame
An ordinary function runs to a return and its stack frame is destroyed. A function containing yield does not run at all when you call it. You get a generator object that owns a frame which has never started.
Every next() resumes that frame, runs until the next yield, hands back the value, and freezes the frame again. Local variables, instruction pointer and all. That is the whole mechanism, and it is why yield can appear in the middle of a loop and still work. The loop variable survives between resumes. This is because the frame was never torn down.
What laziness buys, in one number
The list version of a million squares allocates a million integers and a million pointers before you read the first one. The generator version allocates one object with a frame in it. And the editor below prints both sizes: a few megabytes against about a hundred bytes.
The other half is latency. A generator can hand you its first item at once. This matters when the sequence is being read from a file or a network, and matters absolutely when the sequence is infinite. itertools.count() is a valid generator and an impossible list.
The three things you give up
It is one-shot. Once exhausted, it stays exhausted. And a second for loop over it runs zero times without error. This is the bug people actually hit: passing a generator to two functions and finding the second one saw an empty sequence.
No len(), no indexing, no slicing. The length is not known without running it. So there is nothing to report. itertools.islice is the slicing replacement.
Exceptions surface late. A generator that will raise on its fourth item raises on the fourth next(). This may be a long way from where it was created. Inside a different function, or after a with block has already closed the file it was reading.
The follow-up you should expect
"What is the difference between a generator and an iterator?" An iterator is the protocol. Anything with __next__ and __iter__. A generator function is one common way to implement an iterator; classes and built-in objects can implement the protocol directly too.
And "what does a generator expression change?" Nothing but the syntax: (x*x for x in xs) creates a generator object without building a list. That is why sum(x*x for x in xs) can process the values as it iterates.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
An exception raised inside the generator surfaces at the next() that triggers it, not where the generator was created - which can be in a different function. After the with block that opened the file has already closed it. Generators that read resources should own the with, not be handed an open handle.
return inside a generator sets StopIteration.value rather than producing an item. yield from reads that value; a plain for loop discards it silently.
Closing matters when there is cleanup. Abandoning a suspended generator in time runs its finally at collection time. This is not a schedule you control. contextlib.closing or an explicit gen.close() makes it deterministic.