Python Data Types

Every value in Python belongs to a data type – a category that determines what the value can do, how it is stored, and which operations make sense on it. Numbers can be added, strings can be sliced, and lists can be appended to, and Python decides how to treat a value based on its type rather than a declaration you write yourself. Understanding Python’s built-in data types, and the crucial distinction between mutable and immutable ones, is one of the most important foundations for writing correct, bug-free Python code.

Overview: How Data Types Work in Python

Python is dynamically typed: you never declare a variable’s type ahead of time. Instead, every value you create is an object living somewhere in memory, and that object carries its type with it. A variable name is just a label, a reference, pointing at that object. When you write x = 5, Python creates an integer object with the value 5 and makes the name x point to it. Writing x = 'hello' afterward does not change that integer object at all; it creates a brand-new string object and re-points x at that instead. The variable itself has no fixed type – only the object it currently references does. You can check any object’s type at runtime with the built-in type() function, and check identity (whether two names point to the exact same object) with the is operator or the id() function.

Python’s built-in types fall into two crucial camps: immutable types, whose objects can never be changed after creation (numbers, strings, tuples, frozensets, booleans, and None), and mutable types, whose objects can be modified in place (lists, dictionaries, sets, and bytearrays). This distinction matters enormously in practice. Reassigning a variable like x = x + 1 never mutates the original integer object – it creates a new one and rebinds x to it. But calling my_list.append(4) mutates the existing list object directly, and every other variable name pointing at that same list will see the change. This is why passing a list into a function and modifying it there affects the caller’s list, while passing an integer or string never does – arguments are passed by object reference, and what happens next depends entirely on whether the referenced object allows in-place changes.

Python also groups types by structure: scalar types hold a single value (int, float, complex, bool, None), sequence types hold ordered collections (str, list, tuple, range, bytes), and mapping and set types hold key-based or unordered collections (dict, set, frozenset). Every one of these is, under the hood, an instance of a class – even integers and booleans are objects with methods, which is why (5).bit_length() and 'hi'.upper() both work the same way: dot-access on an object.

Syntax

You never formally declare a variable’s type in Python – the type is always inferred from whatever literal or constructor you assign. The two tools you will use constantly to work with types are type() and isinstance():

variable_name = 42
print(type(variable_name))
print(isinstance(variable_name, int))

Output:

<class 'int'>
True
  • variable_name = 42 – assignment infers the type (int) from the literal on the right.
  • type(variable_name) – returns the exact type object of the value currently referenced.
  • isinstance(variable_name, int) – returns True or False, and correctly accounts for subclasses (for example, bool is a subclass of int).

The table below summarizes Python’s core built-in types:

Type Example Mutable? Description
int 42 No Arbitrary-precision whole number
float 3.14 No Double-precision (IEEE 754) decimal number
complex 2+3j No Number with real and imaginary parts
bool True, False No Subtype of int (True == 1)
str 'hi' No Immutable sequence of Unicode characters
list [1, 2, 3] Yes Ordered, resizable sequence
tuple (1, 2, 3) No Ordered, fixed-length sequence
dict {'a': 1} Yes Ordered key-value mapping
set {1, 2, 3} Yes Unordered collection of unique items
frozenset frozenset([1, 2]) No Immutable version of set
bytes b'hi' No Immutable sequence of raw bytes
NoneType None No Represents the absence of a value

Examples

Example 1: Inspecting every core type

The type() function reveals the exact type behind any value. This example creates one value of each major built-in type and prints its repr alongside its type name:

whole_number = 42
decimal_number = 3.14
complex_number = 2 + 3j
text = 'Python'
is_active = True
nothing = None
fruits = ['apple', 'banana', 'cherry']
coordinates = (10.0, 20.0)
unique_ids = {101, 102, 103}
person = {'name': 'Ava', 'age': 30}

values = (
    whole_number, decimal_number, complex_number, text,
    is_active, nothing, fruits, coordinates, unique_ids, person,
)

for value in values:
    print(f'{value!r} -> {type(value).__name__}')

Output:

42 -> int
3.14 -> float
(2+3j) -> complex
'Python' -> str
True -> bool
None -> NoneType
['apple', 'banana', 'cherry'] -> list
(10.0, 20.0) -> tuple
{101, 102, 103} -> set
{'name': 'Ava', 'age': 30} -> dict

Notice that type(value).__name__ gives the type’s plain name, and that bool is reported as its own type even though it behaves like an integer internally. The {value!r} conversion uses repr() instead of str(), which is why the string shows its quotes ('Python') – a handy way to see exactly what kind of value you are looking at.

Example 2: Converting between types

Real programs constantly convert data – user input arrives as text, calculations need numbers, and output needs to become text again. This example reads scores as strings, converts them to numbers, and demonstrates a few conversion gotchas:

raw_scores = ['88', '92.5', '76', '100']

total = 0.0
for raw in raw_scores:
    total += float(raw)

average = total / len(raw_scores)
print(f'Average score: {average}')

count_as_text = str(len(raw_scores))
print('Number of scores: ' + count_as_text)

print(int(9.9))
print(int('42'))
print(bool(''))
print(bool('0'))
print(bool(0))
print(list('abc'))

Output:

Average score: 89.125
Number of scores: 4
9
42
False
True
False
['a', 'b', 'c']

Two conversions here are easy to get wrong. int(9.9) truncates toward zero rather than rounding, so it gives 9, not 10. And bool('0') gives True – every non-empty string is truthy in Python, including the string '0', even though the number 0 itself is falsy. Only an empty string, an empty collection, 0, 0.0, and None are falsy.

Example 3: Mutable vs. immutable in a realistic scenario

This example builds a small shopping cart to show how mutable objects behave differently from immutable ones when you assign or pass them around:

def add_item(cart: list[str], item: str) -> list[str]:
    cart.append(item)
    return cart

shopping_cart = ['bread', 'milk']
same_cart = shopping_cart
copied_cart = shopping_cart.copy()

add_item(shopping_cart, 'eggs')

print('shopping_cart:', shopping_cart)
print('same_cart:', same_cart)
print('copied_cart:', copied_cart)
print('same_cart is shopping_cart:', same_cart is shopping_cart)
print('copied_cart is shopping_cart:', copied_cart is shopping_cart)

price = 20.0
discounted_price = price
discounted_price -= 5.0

print('price:', price)
print('discounted_price:', discounted_price)

Output:

shopping_cart: ['bread', 'milk', 'eggs']
same_cart: ['bread', 'milk', 'eggs']
copied_cart: ['bread', 'milk']
same_cart is shopping_cart: True
copied_cart is shopping_cart: False
price: 20.0
discounted_price: 15.0

same_cart = shopping_cart does not copy the list – it makes same_cart point at the exact same list object, so mutating it through either name, or inside add_item, is visible everywhere. Calling .copy() creates a genuinely separate list, so copied_cart is unaffected. Compare that to price: floats are immutable, so discounted_price -= 5.0 cannot change the object price refers to – it creates a brand-new float and rebinds only discounted_price, leaving price untouched.

Under the Hood: References and Object Identity

Every name in Python is a pointer to an object stored on the heap. Two names can point to the same object, in which case is returns True and mutating through one name affects the other, or to two different but equal-valued objects, in which case == returns True but is returns False. This example makes both cases visible:

a = [1, 2, 3]
b = a
c = [1, 2, 3]

print(a == b)
print(a == c)
print(a is b)
print(a is c)

b.append(4)
print(a)
print(c)

Output:

True
True
True
False
[1, 2, 3, 4]
[1, 2, 3]

a and c hold equal values but are different objects in memory, so a == c is True while a is c is False. a and b, on the other hand, are the same object – appending through b is really appending to the one list that both names reference, so a changes too even though the code never wrote a.append(4). CPython also caches small integers from -5 to 256, so x = 100; y = 100; x is y is reliably True for numbers in that range – but relying on is for numbers outside that range, or for equal-looking strings built at runtime, is an implementation detail and should be avoided. Always use == to compare values, and reserve is for identity checks like value is None.

Common Mistakes

Mistake 1: Mutable default arguments

A default argument is evaluated once, when the function is defined, not each time it is called. If that default is a mutable object like a list, every call that does not supply its own argument shares the same object:

def add_student(name, roster=[]):
    roster.append(name)
    return roster

class_a = add_student('Maya')
class_b = add_student('Liam')

print(class_a)
print(class_b)

Output:

['Maya', 'Liam']
['Maya', 'Liam']

Both rosters end up identical, because roster=[] created one list at definition time that every call mutates. The fix is to default to None and create a fresh list inside the function body:

def add_student(name, roster=None):
    if roster is None:
        roster = []
    roster.append(name)
    return roster

class_a = add_student('Maya')
class_b = add_student('Liam')

print(class_a)
print(class_b)

Output:

['Maya']
['Liam']

Mistake 2: Comparing floats with ==

Floats are stored in binary IEEE 754 format, and most decimal fractions, including simple-looking ones like 0.1, cannot be represented exactly. That makes direct equality checks unreliable:

total = 0.1 + 0.2

if total == 0.3:
    print('Totals match')
else:
    print('Totals do not match')

print(total)

Output:

Totals do not match
0.30000000000000004

The fix is to compare with a tolerance using math.isclose() instead of ==:

import math

total = 0.1 + 0.2

if math.isclose(total, 0.3):
    print('Totals match')

print(total)

Output:

Totals match
0.30000000000000004

Best Practices

  • Use isinstance(value, SomeType) instead of type(value) == SomeType – it also works correctly with subclasses.
  • Prefer immutable types (tuple, frozenset, str) for data that should not change; they are safer to share and can be used as dictionary keys or set members.
  • Never use a mutable object (list, dict, set) as a default argument value – default to None and build the mutable object inside the function.
  • Compare floating-point numbers with math.isclose(), not ==.
  • Use is and is not only for identity checks like value is None, never as a substitute for ==.
  • Add type hints (count: int, names: list[str]) to function signatures for readability and tooling support, remembering that Python does not enforce them at runtime.
  • Use .copy() (or copy.deepcopy() for nested structures) when you need an independent copy of a mutable object, not just a second reference to it.
  • Convert user input explicitly and defensively – wrap conversions like int(user_input) in a try/except ValueError block rather than assuming the input is well-formed.

Practice Exercises

Exercise 1: Write a program that stores a temperature as a string (for example '36.6'), converts it to a float, and prints whether it is above, below, or equal to 37.0.

Exercise 2: Create a list, assign it to a second variable name, and create a third variable using .copy(). Append an item through the original list and print all three variables to confirm which ones changed.

Exercise 3: Write a function describe(value) that accepts any value and returns a string describing its type name and whether it is mutable or immutable (hint: use type() and a lookup of known mutable type names, such as {'list', 'dict', 'set'}).

Summary

  • Python is dynamically typed: variables are references to objects, and every object carries its own type.
  • Types split into immutable (numbers, strings, tuples, frozensets, bool, None) and mutable (lists, dicts, sets) – this determines whether in-place changes are possible and whether shared references see each other’s edits.
  • Use type() to inspect a value’s exact type and isinstance() for type checks that respect subclassing.
  • == compares values; is compares identity, whether two names point to the same object – they are not interchangeable.
  • Common pitfalls include mutable default arguments, truncating floats with int(), truthy non-empty strings like '0', and comparing floats with ==.
  • Converting between types explicitly with int(), float(), str(), bool(), and list() is a core everyday skill.