What are the major differences between Python 2 and Python 3 that still matter today?
Quick Answer
The most consequential change: **strings are Unicode by default in Python 3** (`str` is text, `bytes` is binary — no more implicit, error-prone mixing of the two as in Python 2's `str`/`unicode` split). Other lasting changes: `print` became a function, `/` performs true division by default (`//` for floor division), and iteration-heavy built-ins (`range`, `dict.keys()`, `map`, `filter`) return lazy iterators/views instead of lists. Python 2 reached end-of-life in January 2020.
Detailed Answer
The biggest change: text vs binary data
# Python 2 (historical) -- 'str' was actually a byte string; 'unicode' was text
s = "hello" # bytes, in Python 2
u = u"hello" # explicit unicode, in Python 2
s + u # implicit conversion -- worked until it silently didn't, on non-ASCII data
# Python 3 -- unambiguous
s = "hello" # str -- ALWAYS Unicode text
b = b"hello" # bytes -- ALWAYS binary data
s + b # TypeError: can't concat str to bytes -- caught immediately, not silently wrong
Python 2's implicit str/unicode mixing was a constant, subtle source of
UnicodeDecodeError crashes in production whenever non-ASCII data
appeared somewhere unexpected. Python 3 makes the distinction explicit
and enforced at the type level — you must deliberately .encode()/
.decode() to cross between text and bytes, which surfaces the issue
immediately during development instead of as an intermittent production
bug.
print as a function, not a statement
# Python 2
print "hello"
# Python 3
print("hello")
Making print a real function (rather than special statement syntax)
allows it to accept keyword arguments (sep, end, file, flush) and
be passed around/reassigned like any other function — a small but
representative example of Python 3's broader push toward consistency.
True division by default
# Python 2
5 / 2 # 2 -- integer division by default (surprising for many)
5 / 2.0 # 2.5
# Python 3
5 / 2 # 2.5 -- true division by default
5 // 2 # 2 -- floor division, now an explicit, separate operator
Python 2's / silently performed integer division when both operands
were ints — a frequent source of subtle bugs when a variable that was
expected to be a float turned out to be an int. Python 3 splits this into
two unambiguous operators.
Lazy iterators instead of eager lists
# Python 2
range(10) # a full list: [0, 1, 2, ..., 9] -- built immediately in memory
dict.keys() # a list
# Python 3
range(10) # a range object -- lazy, O(1) memory regardless of size
dict.keys() # a view object -- reflects live changes to the dict, no list copy
map(f, items) # a lazy iterator, not an eagerly built list
This shift (also affecting filter, zip) reflects Python 3's general
preference for laziness by default, improving memory efficiency for
large ranges/collections — code relying on range(...) behaving like an
indexable, sliceable list still mostly works (since range supports
indexing/slicing), but code relying on it being an actual list (e.g.,
isinstance(x, list)) breaks.
Why this still matters today
Python 2 reached official end-of-life on January 1, 2020 — no more security patches, and most major libraries (NumPy, Django, and virtually the entire PyPI ecosystem) dropped Python 2 support around the same time. It matters in interviews mainly as historical/foundational knowledge: understanding why Python 3 made these changes (especially the Unicode/bytes split) demonstrates a solid grasp of Python's string and type model that's directly relevant even though nobody should be writing new Python 2 code today.
Interview-ready summary: The Unicode/bytes split (str is always
text, bytes is always binary, no implicit mixing) is the change with
the most lasting practical impact, eliminating a whole class of Python 2
encoding bugs. print() as a function, true division by default, and
lazy iterators instead of eagerly built lists rounded out Python 3's
broader push toward consistency and correctness — Python 2 itself is
long past end-of-life (January 2020) and shouldn't appear in new code.