The Essential Guide to Basic Data Types in Python


Python is often celebrated for its readability, simplicity, and the fact that you can write code that looks suspiciously like English. But beneath this friendly facade lies a language built on a set of powerful, flexible data types that make everything tick—from the simplest “Hello, World!” script to complex machine learning models. Understanding these basic data types isn’t just about syntax; it’s about grasping the building blocks of how Python handles data.


Numbers

Let’s start with the most primitive of primitive types—numbers. In Python, numbers aren’t just numbers. They come with personalities, quirks, and, occasionally, the ability to break your code if you’re not careful.

Integers (int)

An integer in Python represents whole numbers—positive, negative, or zero. You don’t need to declare a variable type. Just assign a number, and Python will handle the rest.

a = 42
b = -17
c = 0

You can perform the usual arithmetic operations: addition (+), subtraction (-), multiplication (*), and division (/).

print(5 + 3)   # 8
print(10 - 4) # 6
print(7 * 6) # 42

Here’s where things get interesting. In Python, division with / always returns a floating-point number, even if the division is exact.

print(8 / 4)  # 2.0 (not 2!)

If you want integer division (i.e., dropping the decimal), use the floor division operator //.

print(8 // 4)  # 2
print(7 // 2) # 3 (because 3.5 gets floored to 3)

Python also supports arbitrarily large integers. Unlike languages with fixed integer sizes, Python lets you work with huge numbers without overflowing.

big_number = 1234567890123456789012345678901234567890
print(big_number * big_number)

No special syntax. No long keyword like in the old Python 2 days. Just type the number, and Python handles the rest.

Floating-Point Numbers (float)

A float represents real numbers, including decimals. Simple enough, right?

pi = 3.14159
e = 2.71828
negative_float = -0.01

But floats come with a warning label: floating-point precision errors. Computers can’t represent all decimal numbers exactly, leading to fun surprises like this:

print(0.1 + 0.2)  # 0.30000000000000004

Don’t panic. This isn’t a bug; it’s a feature of how computers handle binary floating-point arithmetic. If you’re dealing with money or need precise decimals, use the Decimal class from the decimal module.

from decimal import Decimal

result = Decimal('0.1') + Decimal('0.2')
print(result) # 0.3

Complex Numbers (complex)

If you thought Python was just for boring real numbers, think again. Python has built-in support for complex numbers, using j to denote the imaginary part.

z = 3 + 4j
print(z.real) # 3.0
print(z.imag) # 4.0

You can perform arithmetic with complex numbers as if you’re casually solving electrical engineering problems over coffee.

a = 2 + 3j
b = 1 - 5j
print(a + b) # (3-2j)
print(a * b) # (17-7j)

Strings

Strings are how we represent text in Python. They’re enclosed in single quotes (‘…’) or double quotes (“…”). Python doesn’t discriminate.

greeting = "Hello, World!"
quote = 'Python is fun.'

If you need to include quotes inside your string, just switch the type of quotes.

sentence = "She said, 'Python is amazing!'"

Or escape them with a backslash (\):

escaped = 'It\'s a beautiful day.'

When your text is too verbose for a single line, use triple quotes:

poem = """
Roses are red,
Violets are blue,
Python is awesome,
And so are you.
"""
print(poem)

Strings are immutable. Once created, you can’t change them. Any operation that seems to modify a string actually creates a new one.

Booleans

Booleans are Python’s way of saying yes (True) or no (False). Simple as that.

is_python_fun = True
is_java_better = False

Python also treats some values as truthy (considered True) and falsy (considered False):

  • Falsy: 0, ” (empty string), [] (empty list), {} (empty dict), None
  • Truthy: Anything that’s not falsy

NoneType

None is Python’s way of saying “nothing here.” It’s not zero. It’s not an empty string. It’s literally nothing.

result = None
print(result) # None

Lists

Lists are ordered, mutable collections.

fruits = ["apple", "banana", "cherry"]
numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", 3.0, True, None]

You can access and modify their elements:

print(fruits[0])        # "apple"
fruits[1] = "blueberry" # Modify element
print(fruits) # ["apple", "blueberry", "cherry"]

Add and remove items:

fruits.append("date")
print(fruits) # ["apple", "blueberry", "cherry", "date"]

fruits.remove("apple")
print(fruits) # ["blueberry", "cherry", "date"]

Lists can be nested:

matrix = [[1, 2, 3], [4, 5, 6]]
print(matrix[1][2]) # 6

Tuples

Tuples are like lists, but immutable. Once created, you can’t change them.

coordinates = (4, 5)
colours = ("red", "green", "blue")

Why use tuples? Because immutability ensures data integrity. Plus, they’re faster than lists.

You can unpack tuples like this:

x, y = coordinates
print(x) # 4
print(y) # 5

Dictionaries

Dictionaries are Python’s version of hash maps—collections of key-value pairs.

person = {
"name": "Alice",
"age": 30,
"city": "Wonderland"
}

Access values by keys:

print(person["name"])  # "Alice"

Add or modify entries:

person["age"] = 31
person["email"] = "alice@example.com"

Sets

Sets are unordered collections of unique elements. They’re great for removing duplicates.

numbers = {1, 2, 3, 4, 4, 5}
print(numbers) # {1, 2, 3, 4, 5}

Add and remove elements:

numbers.add(6)
numbers.remove(3)

Frozensets

In Python, a frozenset is an immutable version of the built-in set data type. Like a set, a frozenset is an unordered collection of unique elements, but once a frozenset is created, its elements cannot be changed, added, or removed. This immutability makes frozensets hashable, which means they can be used as keys in dictionaries or elements in other sets.

# Create a frozenset
my_frozenset = frozenset([1, 2, 3, 4])

# Attempting to add or remove elements will result in an error
# my_frozenset.add(5) # This would raise an AttributeError

Mutability vs. Immutability: The Great Divide

Before we dive into the more exotic features, let’s revisit a concept that underpins how Python treats data: mutability.

In simple terms, mutable objects can be changed after they’re created. Immutable objects cannot be changed once they’ve been created. Think of mutability like having a whiteboard. A mutable whiteboard lets you write and erase things freely. An immutable whiteboard, on the other hand, is like a stone tablet—once it’s carved in, that’s it. You’d need to create an entirely new stone tablet to make changes.

The immutable data types are:

  • Integers
  • Floating-point numbers
  • Strings
  • Tuples
  • Frohestes

These are the mutable data types:

  • Lists
  • Dictionaries
  • Sets

Here’s where things get tricky. Consider this innocent-looking code:

a = [1, 2, 3]
b = a
b.append(4)

print(a) # [1, 2, 3, 4]

Wait, what? We modified b, but a changed too. That’s because both a and b point to the same list in memory. They’re not copies of each other—they’re just two names for the same object. Lists are mutable, so when you modify one reference, all references to that object reflect the change.

Now, let’s contrast that with an immutable type:

x = 10
y = x
y += 5

print(x) # 10
print(y) # 15

Here, modifying y doesn’t affect x because integers are immutable. Instead of changing the original integer, Python creates a new integer object for y and updates the reference.

The collections Module

Python’s standard library includes the collections module, which provides specialised data structures beyond basic lists, dictionaries, and tuples. To use something that is defined in a module, we use the from statement.

namedtuple

A namedtuple is like a regular tuple, but with named fields for better readability.

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(10, 20)

print(p.x) # 10
print(p.y) # 20

You get the immutability and efficiency of tuples, but with the clarity of named attributes.

deque

A deque (pronounced “deck”) is a list optimized for fast appends and pops from both ends.

from collections import deque

d = deque([1, 2, 3])
d.append(4)
d.appendleft(0)

print(d) # deque([0, 1, 2, 3, 4])

d.pop() # Removes 4
d.popleft() # Removes 0

While lists are fine for most use cases, deque shines in queue and stack implementations where performance matters.

Counter

A Counter is a dictionary subclass for counting occurrences of elements.

from collections import Counter

words = ["apple", "banana", "apple", "orange", "banana", "apple"]
counter = Counter(words)

print(counter) # Counter({'apple': 3, 'banana': 2, 'orange': 1})
print(counter['apple']) # 3

This is perfect for frequency analysis, such as counting characters, words, or events.

defaultdict

A defaultdict provides default values for missing keys, so you don’t have to check if a key exists before adding to it.

from collections import defaultdict

d = defaultdict(int)
d['apple'] += 1
d['banana'] += 1

print(d) # defaultdict(<class 'int'>, {'apple': 1, 'banana': 1})

No need to initialize keys manually. It’s particularly useful when grouping data:

grouped = defaultdict(list)
grouped['fruits'].append('apple')
grouped['fruits'].append('banana')

print(grouped) # defaultdict(<class 'list'>, {'fruits': ['apple', 'banana']})

Quirks, Oddities, and Hidden Behaviors

After traversing the landscapes of Python’s basic and advanced data types, understanding how to use them, and even peeking under the hood to see how Python treats them internally, you might feel like you’ve seen it all. But Python, being the mischievous language it is, always has a few tricks up its sleeve.

This final section in our data type odyssey isn’t about polished features or well-documented behaviours—it’s about the quirks, the curiosities, and the little things that make you squint at your screen and wonder, “Why does it do that?” Some of these are the result of design decisions dating back to Python’s earliest days, while others are happy (or not-so-happy) accidents that have persisted through versions.

So, pour yourself a cup of coffee, stretch your debugging muscles, and let’s dive into the strange, wonderful world of Python’s data type oddities.

The Bizarre Integer Caching Mechanism

Python has a sneaky optimization trick called integer caching. For performance reasons, Python pre-allocates and reuses small integer objects (typically in the range of -5 to 256). This leads to some surprising behaviour.

a = 256
b = 256
print(a is b) # True

a and b point to the same object in memory. But watch what happens when we go beyond 256:

x = 257
y = 257
print(x is y) # False

Wait, what? Now x and y are different objects, even though they have the same value. That’s because integers larger than 256 aren’t cached. Python creates new objects for them.

Interestingly, this behaviour can vary depending on how the integers are created:

print(257 is 257)        # May return True (because of compiler optimizations)
print(int('257') is int('257')) # Always False (new objects created)

The takeaway? Never use is to compare numbers. Use == instead. is checks identity (same object), while == checks equality (same value).

Floating-Point Arithmetic: The Great Betrayal

Floating-point numbers in Python (and most programming languages) are based on the IEEE 754 standard, which introduces precision errors due to binary representation. Let’s revisit the example given above:

print(0.1 + 0.2)  # 0.30000000000000004

It’s not a bug. It’s just how floating-point math works. But the real quirk is when you try to compare floating-point numbers directly:

print(0.1 + 0.2 == 0.3)  # False

The solution is to use a tolerance when comparing floats:

import math
print(math.isclose(0.1 + 0.2, 0.3)) # True

Python even introduced the decimal module for exact decimal arithmetic:

from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2') == Decimal('0.3')) # True

But here’s where the fun starts. Mixing Decimal with floats leads to chaos:

print(Decimal('0.1') + 0.2)  # TypeError

Python draws a hard line between precise and imprecise numbers. You either live in the world of floats or decimals—no middle ground.

Mutable Default Arguments

A Classic Python Gotcha! This is one of those quirks that even experienced Python developers occasionally stumble over. Consider this function:

def append_to_list(value, my_list=[]):
my_list.append(value)
return my_list

print(append_to_list(1)) # [1]
print(append_to_list(2)) # [1, 2]
print(append_to_list(3)) # [1, 2, 3]

Why is the list accumulating values across function calls? Shouldn’t my_list reset to an empty list each time?

Here’s the quirk: default arguments are evaluated only once when the function is defined, not each time it’s called. So the same list object is reused.

The fix? Use None as the default value and initialize the list inside the function:

def append_to_list(value, my_list=None):
if my_list is None:
my_list = []
my_list.append(value)
return my_list

Now each call gets its own list.

The Mystery of bool Being a Subclass of int

In Python, True and False aren’t just boolean values. They’re actually instances of the int class.

print(isinstance(True, int))  # True
print(True + True) # 2
print(False * 100) # 0

Why? Because in Python’s type hierarchy, bool is a subclass of int. This design decision was made for simplicity and backward compatibility with older versions of Python.

But it leads to some odd behavior:

print(True == 1)   # True
print(False == 0) # True
print(True is 1) # False

So, while True and 1 are equal, they’re not the same object. This can cause subtle bugs in code that relies on strict type checking.

The Infamous += and Mutable Objects

Consider this:

a = [1, 2, 3]
b = a
a += [4, 5]

print(a) # [1, 2, 3, 4, 5]
print(b) # [1, 2, 3, 4, 5]

Both a and b are modified. But now look at this:

x = (1, 2, 3)
y = x
x += (4, 5)

print(x) # (1, 2, 3, 4, 5)
print(y) # (1, 2, 3)

Wait… what? Why didn’t y change?

The key is that += behaves differently for mutable and immutable types. For lists, += modifies the list in place. But for tuples (which are immutable), += actually creates a new tuple and reassigns x, leaving y unchanged.

String Interning

Python optimizes memory usage by interning certain strings—storing only one copy of immutable strings that are commonly used. This leads to surprising behavior with string comparisons.

a = "hello"
b = "hello"
print(a is b) # True

But:

x = "hello world!"
y = "hello world!"
print(x is y) # Might be False

Why? Short strings and identifiers are often interned automatically, but longer strings or those created at runtime might not be.

Interning helps with performance, especially when comparing large numbers of strings.

The Strange Behavior of + vs * with Lists

Consider this:

a = [[0] * 3] * 3
print(a)

You might expect a 3×3 grid of zeros. And at first glance, that’s what you get:

[[0, 0, 0], [0, 0, 0], [0, 0, 0]]

But now:

a[0][0] = 1
print(a)

Suddenly:

[[1, 0, 0], [1, 0, 0], [1, 0, 0]]

What happened? The * operator didn’t create independent lists. It created multiple references to the same inner list. So changing one changes them all.

Embrace the Quirks

Python’s quirks aren’t bugs—they’re features. They’re the result of design decisions made to balance performance, simplicity, and flexibility. Some quirks make Python more efficient; others are historical artifacts from earlier versions. But understanding them doesn’t just help you avoid bugs—it gives you deeper insight into how Python works under the hood.

And honestly, that’s part of the charm. Python isn’t just a language; it’s a living, breathing ecosystem with its own personality. Its quirks are like the odd habits of an old friend—endearing, occasionally frustrating, but ultimately what makes it unique.

So the next time Python does something unexpected, don’t get annoyed. Get curious. Because somewhere in that behavior is a story, a reason, and maybe even a lesson worth learning.

Leave a comment