A generator, such as the object returned by your generator expression is a lazy iterator. It only runs as much code as it needs to provide you with the values you ask for. If you don't iterate on the generator, you don't ask for any values, so it doesn't run any code and so nothing gets printed.
You can request individual values from an iterator by calling next on it:
gen = (print(i) for i in range(5))
x = next(gen) # causes 0 to be printed
y = next(gen) # causes 1 to be printed
The first call to next asks for the first value from the generator, so it calls print with the first value from the range. The second call to next will cause the second value from the range to be printed. Both x and y will be assigned None, since that's what print returns, and so what is yielded by the generator.
If you put the generator in a for loop (or pass it to a function like list that iterates over an argument), the whole generator will be consumed, just like if you called next on it a bunch of times. If you kept calling next manually, you'd eventually get a StopIteration exception, which is how an iterator signals that it has no more values to yield.
The list comprehension you were trying didn't have the same behavior because unlike a generator, a list comprehension is not lazy. It runs all the code immediately, and creates a list from all the values it gets. Your list comprehension will create a list full of None values (since that's what print returns), and all the numbers will be printed as the list is being filled.
Note that neither of the versions of your code are very Pythonic. You generally shouldn't use either generator expressions or list comprehensions only for their side effects (such as printing values). Use them when you care about the values being yielded (or put in the list). If you just want to print a range of numbers, just use a for loop on the range directly, and call print in the body of the loop:
for i in range(5):
    print(i)
It's acceptable list comprehension or generator expression with code that does have side effects, as long as the value being computed is also useful. For example, if you wanted to send a message to a bunch of clients over an unreliable network, you might use some code that looked something like this:
results = [client.send(message) for client in clients] # client.send may return an error code
for result in results:  # process the results after all the messages were sent
    if result is not None:  # no error means success
        print("client.send error:", result)  # report failures, but keep going
The list comprehension on the first line is mostly being used for its side effects (sending the messages), but the list of return values is also useful, since it lets us see what errors occurred.