Python Data Types: A Complete Guide

Python does not just store values — it wraps every value in an object that carries a type, a reference count, and a set of behaviors. Understanding what type a value is, whether that type can be changed, and how Python decides to copy or share an object explains a whole category of bugs that beginners hit over and over. This guide works through every core data type with running code and output so you can see exactly what Python is doing.

All of the code blocks in this article are runnable Python 3. Paste them into a .py file or drop them into a REPL and the output will match what is shown.

Everything Is an Object

In Python, every value is an object — not just class instances, but integers, strings, functions, and even None. Each object has three properties: an identity (its address in memory), a type (what category of object it is), and a value (the data it holds). You can inspect all three directly.

# Every value is an object with identity, type, and value
x = 42
print(f"value    : {x}")
print(f"type     : {type(x)}")
print(f"identity : {id(x)}")   # memory address (implementation detail)

# The same three properties on a string
s = "hello"
print(f"\nvalue    : {s}")
print(f"type     : {type(s)}")
print(f"identity : {id(s)}")

# Even a function is an object
def greet(): pass
print(f"\ntype of a function : {type(greet)}")
print(f"type of a type     : {type(int)}")
print(f"type of None       : {type(None)}")
value : 42 type : <class 'int'> identity : 140234567890176 value : hello type : <class 'str'> identity : 140234567123456 type of a function : <class 'function'> type of a type : <class 'type'> type of None : <class 'NoneType'>

The identity number will differ on every machine and every run — it is just the memory address CPython happens to choose. What matters is that you can always ask Python what type something is, and Python always knows.

Numeric Types: int, float, complex

Python has three built-in numeric types. int holds whole numbers of arbitrary size — there is no overflow. float uses 64-bit IEEE 754 double precision. complex stores a pair of floats representing a complex number.

# int — arbitrary precision, no overflow
big = 2 ** 100
print(f"2**100 = {big}")
print(f"type   = {type(big)}")

# Underscores as visual separators (Python 3.6+)
population = 8_100_000_000
budget     = 1_000_000
print(f"\npopulation : {population:,}")
print(f"budget     : {budget:,}")

# float — 64-bit IEEE 754
pi = 3.141592653589793
print(f"\npi           = {pi}")
print(f"type         = {type(pi)}")
print(f"float max    = {float('inf')}")    # positive infinity
print(f"float('nan') = {float('nan')}")   # not a number

# The classic float precision issue
print(f"\n0.1 + 0.2       = {0.1 + 0.2}")
print(f"0.1 + 0.2 == 0.3 : {0.1 + 0.2 == 0.3}")

import math
print(f"math.isclose    : {math.isclose(0.1 + 0.2, 0.3)}")

# complex — real + imaginary parts
c = 3 + 4j
print(f"\ncomplex : {c}")
print(f"real    : {c.real}")
print(f"imag    : {c.imag}")
print(f"abs     : {abs(c)}")   # magnitude = sqrt(3^2 + 4^2) = 5.0
2**100 = 1267650600228229401496703205376 type = <class 'int'> population : 8,100,000,000 budget : 1,000,000 pi = 3.141592653589793 type = <class 'float'> float max = inf float('nan') = nan 0.1 + 0.2 = 0.30000000000000004 0.1 + 0.2 == 0.3 : False math.isclose : True complex : (3+4j) real : 3.0 imag : 4.0 abs : 5.0
Never use float for money

The 0.1 + 0.2 != 0.3 result above is not a Python bug — it is a consequence of binary floating-point representation. For financial calculations, use from decimal import Decimal and construct values from strings: Decimal("0.10") + Decimal("0.20") equals exactly Decimal("0.30").

bool — The int in Disguise

bool is a subclass of int. True is literally the integer 1 and False is literally 0. This means booleans participate in arithmetic — which is occasionally useful, and occasionally the source of very confusing bugs.

# bool is a subclass of int
print(isinstance(True, int))    # True
print(isinstance(True, bool))   # True

print(f"\nTrue  == 1 : {True == 1}")
print(f"False == 0 : {False == 0}")
print(f"True is 1  : {True is 1}")   # False — different types, different objects

# Booleans in arithmetic
flags = [True, False, True, True, False, True]
count_true = sum(flags)    # sum treats True as 1, False as 0
print(f"\nflags        : {flags}")
print(f"sum(flags)   : {count_true}   (counts True values)")
print(f"True  + True : {True + True}")
print(f"True  * 10   : {True * 10}")

# Every object has a truth value — tested by bool()
truthy_tests = [1, -1, 0.001, "x", " ", [0], {"a": 1}]
falsy_tests  = [0, 0.0, "", [], {}, (), None, False]

print("\nTruthy values:")
for v in truthy_tests:
    print(f"  bool({v!r:15}) = {bool(v)}")

print("\nFalsy values:")
for v in falsy_tests:
    print(f"  bool({v!r:10}) = {bool(v)}")
True True True == 1 : True False == 0 : True True is 1 : False flags : [True, False, True, True, False, True] sum(flags) : 4 (counts True values) True + True : 2 True * 10 : 10 Truthy values: bool(1 ) = True bool(-1 ) = True bool(0.001 ) = True bool('x' ) = True bool(' ' ) = True bool([0] ) = True bool({'a': 1} ) = True Falsy values: bool(0 ) = False bool(0.0 ) = False bool('' ) = False bool([] ) = False bool({} ) = False bool(() ) = False bool(None ) = False bool(False ) = False

str — Immutable Text

str is Python's built-in text type — an immutable sequence of Unicode characters. Because strings are immutable, every operation that appears to modify a string actually creates a new string object.

# str is a sequence — indexable and iterable
s = "Python"
print(f"s[0]    = {s[0]}")      # first character
print(f"s[-1]   = {s[-1]}")     # last character
print(f"s[1:4]  = {s[1:4]}")    # slice
print(f"s[::-1] = {s[::-1]}")   # reversed

# Immutability — you cannot change a character in place
try:
    s[0] = "J"
except TypeError as e:
    print(f"\nCannot mutate str: {e}")

# Each 'modification' returns a new object
original = "hello"
upper    = original.upper()
print(f"\noriginal id : {id(original)}")
print(f"upper id    : {id(upper)}")
print(f"same object : {original is upper}")

# String interning — Python caches some short strings
a = "hello"
b = "hello"
print(f"\na is b (interned literal) : {a is b}")  # often True — cached

c = "".join(["hel", "lo"])   # constructed at runtime
print(f"a is c (constructed)      : {a is c}")  # often False

# Three ways to build multi-line strings
sql_query = (
    "SELECT name, stock "
    "FROM inventory "
    "WHERE stock > 0"
)
print(f"\nConcatenated literals:\n  {sql_query}")

report = f"""
Product: {s}
Length : {len(s)}
Upper  : {s.upper()}
""".strip()
print(f"\nf-string multiline:\n{report}")
s[0] = P s[-1] = n s[1:4] = yth s[::-1] = nohtyP Cannot mutate str: 'str' object does not support item assignment original id : 140234512345678 upper id : 140234512399012 same object : False a is b (interned literal) : True a is c (constructed) : False Concatenated literals: SELECT name, stock FROM inventory WHERE stock > 0 f-string multiline: Product: Python Length : 6 Upper : PYTHON

NoneType — The Absence of a Value

None is Python's null value. It is a singleton — there is exactly one None object in any Python process. It represents the absence of a value and is the implicit return value of any function that does not return anything explicitly.

# None is a singleton — only one exists
a = None
b = None
print(f"a is b     : {a is b}")   # always True
print(f"a == b     : {a == b}")   # also True
print(f"type(None) : {type(None)}")

# Functions that don't return explicitly return None
def do_nothing():
    pass

result = do_nothing()
print(f"\ndo_nothing() returned : {result!r}")
print(f"is None               : {result is None}")

# None as a sentinel / missing-value marker
def find_user(user_id, db):
    return db.get(user_id)   # dict.get() returns None if key missing

users = {1: "Alice", 2: "Bob"}

for uid in [1, 3]:
    user = find_user(uid, users)
    if user is None:
        print(f"\nUser {uid} not found")
    else:
        print(f"\nUser {uid}: {user}")

# None vs False vs 0 vs "" — all falsy, but not the same
candidates = [None, False, 0, 0.0, "", [], {}]
for v in candidates:
    print(f"  {v!r:8} is None: {v is None}   bool: {bool(v)}")
a is b : True a == b : True type(None) : <class 'NoneType'> do_nothing() returned : None is None : True User 1: Alice User 3 not found None is None: True bool: False False is None: False bool: False 0 is None: False bool: False 0.0 is None: False bool: False '' is None: False bool: False [] is None: False bool: False {} is None: False bool: False
Always use is, never == for None

Use x is None and x is not None rather than x == None. A custom class can override __eq__ to return something unexpected when compared with ==. Since None is a singleton, is is both safer and clearer.

Mutable vs Immutable — Why It Matters

This is the concept that causes more confusion for Python beginners than any other. When you assign a variable in Python, you are not copying the value — you are creating a name that points at an object. Whether that object can be changed in place depends on whether its type is mutable.

# IMMUTABLE — rebinding creates a new object
x = 10
y = x              # y points at the same int object as x
print(f"Before: x={x}  y={y}  same object: {x is y}")

x = 20             # x now points at a NEW int object
print(f"After:  x={x}  y={y}  same object: {x is y}")
# y is unchanged — it still points at the 10 object

# MUTABLE — mutation affects all names pointing at the object
a = [1, 2, 3]
b = a              # b points at the SAME list object as a
print(f"\nBefore: a={a}  b={b}  same object: {a is b}")

a.append(4)        # mutates the object both a and b point at
print(f"After:  a={a}  b={b}  same object: {a is b}")
# b changed even though we only wrote to a!

# To get an independent copy, you must copy explicitly
c = a.copy()       # or list(a) or a[:]
print(f"\nc = a.copy(): c={c}  same object: {a is c}")
a.append(5)
print(f"After a.append(5): a={a}  c={c}  (c unaffected)")
Before: x=10 y=10 same object: True After: x=20 y=10 same object: False Before: a=[1, 2, 3] b=[1, 2, 3] same object: True After: a=[1, 2, 3, 4] b=[1, 2, 3, 4] same object: True c = a.copy(): c=[1, 2, 3, 4] same object: False After a.append(5): a=[1, 2, 3, 4, 5] c=[1, 2, 3, 4] (c unaffected)

This also explains why using a mutable default argument in a function definition is a classic Python trap:

# MUTABLE DEFAULT ARGUMENT TRAP
# The default list is created ONCE when the function is defined.
# Every call that doesn't pass 'items' shares that same list object.

def add_item_bad(item, items=[]):   # list created once at definition time
    items.append(item)
    return items

print(add_item_bad("apple"))    # ['apple']
print(add_item_bad("banana"))   # ['apple', 'banana']  — not what you wanted!
print(add_item_bad("cherry"))   # ['apple', 'banana', 'cherry']

# CORRECT pattern: use None as default, create fresh list inside
def add_item_good(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print()
print(add_item_good("apple"))    # ['apple']
print(add_item_good("banana"))   # ['banana']   — fresh list each time
print(add_item_good("cherry"))   # ['cherry']
['apple'] ['apple', 'banana'] ['apple', 'banana', 'cherry'] ['apple'] ['banana'] ['cherry']
Type Mutable? Hashable? Notes
intNoYesArbitrary precision
floatNoYes64-bit IEEE 754
complexNoYesPair of floats
boolNoYesSubclass of int
strNoYesUnicode sequence
bytesNoYesImmutable byte sequence
tupleNo*Yes**only if all elements are hashable
NoneTypeNoYesSingleton
listYesNoOrdered, resizable
dictYesNoKey-value pairs, insertion-ordered (3.7+)
setYesNoUnique unordered elements
bytearrayYesNoMutable byte sequence
frozensetNoYesImmutable set

Sequences: list, tuple, range

Sequences are ordered collections where each element has a position you can access by index. Python's three most common sequence types differ in mutability and purpose.

import sys

# list — mutable, heterogeneous, resizable
scores = [92, 87, 95, 74, 88]
scores.append(100)
scores.sort(reverse=True)
print(f"scores (sorted): {scores}")
print(f"top 3          : {scores[:3]}")
print(f"list size      : {sys.getsizeof(scores)} bytes")

# tuple — immutable, often used as a record (fixed structure)
point_3d  = (3.0, -1.5, 7.2)
rgb_red   = (255, 0, 0)
person    = ("Alice", 31, "engineer")

# Tuple unpacking — assigns each element to a name in one line
name, age, role = person
print(f"\n{name} is a {age}-year-old {role}")

# Swap via tuple unpacking — no temp variable needed
a, b = 10, 20
print(f"\nBefore swap: a={a}  b={b}")
a, b = b, a
print(f"After swap : a={a}  b={b}")

# Tuple as dict key (hashable); list as dict key raises TypeError
location_data = {
    (40.7128, -74.0060): "New York",
    (51.5074, -0.1278):  "London",
}
print(f"\n{location_data[(40.7128, -74.0060)]}")

try:
    bad = {[1, 2]: "value"}
except TypeError as e:
    print(f"List as key fails: {e}")

# range — lazy integer sequence, extremely memory-efficient
r = range(0, 1_000_000)
print(f"\nrange(0, 1_000_000) size : {sys.getsizeof(r)} bytes")
print(f"list of same range size  : {sys.getsizeof(list(r))} bytes")
print(f"500_000 in range         : {500_000 in r}")   # O(1) check
scores (sorted): [100, 95, 92, 88, 87, 74] top 3 : [100, 95, 92] list size : 120 bytes Alice is a 31-year-old engineer Before swap: a=10 b=20 After swap : a=20 b=10 New York List as key fails: unhashable type: 'list' range(0, 1_000_000) size : 48 bytes list of same range size : 8000056 bytes 500_000 in range : True

Mappings and Sets: dict, set, frozenset

dict stores key-value pairs. Keys must be hashable (immutable types). Since Python 3.7, dicts preserve insertion order. set stores unique hashable elements with no order. frozenset is an immutable set — hashable and usable as a dict key.

# dict — key-value store, insertion-ordered (Python 3.7+)
product = {
    "name"  : "Widget A",
    "stock" : 42,
    "price" : 4.99,
    "tags"  : ["sale", "featured"],
}

# Safe access — get() returns None (or a default) if key missing
print(product.get("name"))
print(product.get("discount", 0.0))    # key missing — returns default

# Iterating over all three views
print("\nKeys   :", list(product.keys()))
print("Values :", list(product.values()))
print("Items  :", [(k, v) for k, v in product.items() if k != "tags"])

# Merging dicts — Python 3.9+ | operator
defaults = {"stock": 0, "price": 0.0, "active": True}
merged   = defaults | product          # product values win conflicts
print(f"\nmerged active: {merged['active']}")
print(f"merged stock : {merged['stock']}")

# set — unique values, O(1) membership test
raw_tags  = ["sale", "featured", "sale", "new", "featured", "clearance"]
tag_set   = set(raw_tags)
print(f"\nraw_tags : {raw_tags}")
print(f"tag_set  : {tag_set}   (duplicates removed)")

# Set operations
a = {"sale", "featured", "new"}
b = {"new", "clearance", "featured"}

print(f"\na | b (union)        : {a | b}")
print(f"a & b (intersection) : {a & b}")
print(f"a - b (difference)   : {a - b}")
print(f"a ^ b (symmetric diff): {a ^ b}")

# frozenset — immutable, hashable, usable as dict key
perm_set = frozenset({"read", "write"})
role_permissions = {
    frozenset({"read"}):               "viewer",
    frozenset({"read", "write"}):      "editor",
    frozenset({"read", "write", "admin"}): "admin",
}
user_perms = frozenset({"read", "write"})
print(f"\nRole for {user_perms}: {role_permissions.get(user_perms, 'unknown')}")
Widget A 0.0 Keys : ['name', 'stock', 'price', 'tags'] Values : ['Widget A', 42, 4.99, ['sale', 'featured']] Items : [('name', 'Widget A'), ('stock', 42), ('price', 4.99)] merged active: True merged stock : 42 raw_tags : ['sale', 'featured', 'sale', 'new', 'featured', 'clearance'] tag_set : {'clearance', 'featured', 'new', 'sale'} (duplicates removed) a | b (union) : {'clearance', 'featured', 'new', 'sale'} a & b (intersection) : {'featured', 'new'} a - b (difference) : {'sale'} a ^ b (symmetric diff): {'clearance', 'sale'} Role for frozenset({'read', 'write'}): editor

Type Checking: type() vs isinstance()

Python gives you two tools for inspecting types at runtime. They behave differently and the distinction matters more than it first appears.

# type() returns the exact type — no inheritance
values = [42, 3.14, True, "hello", None, [1, 2], (1, 2), {}, set()]

print("type() results:")
for v in values:
    print(f"  type({v!r:12}) = {type(v).__name__}")

# isinstance() respects inheritance
print("\nisinstance() with bool:")
print(f"  isinstance(True, bool) : {isinstance(True, bool)}")
print(f"  isinstance(True, int)  : {isinstance(True, int)}")   # True — bool IS an int
print(f"  type(True) == int      : {type(True) == int}")        # False — exact match fails

# isinstance() accepts a tuple of types — checks any match
def describe(value):
    if isinstance(value, bool):        # check bool BEFORE int (bool is subclass)
        return f"{value!r} is a bool"
    elif isinstance(value, int):
        return f"{value!r} is an int"
    elif isinstance(value, float):
        return f"{value!r} is a float"
    elif isinstance(value, (list, tuple)):
        return f"{value!r} is a sequence of {len(value)} items"
    elif isinstance(value, str):
        return f"{value!r} is a str of length {len(value)}"
    elif value is None:
        return "value is None"
    else:
        return f"unknown type: {type(value).__name__}"

tests = [True, 42, 3.14, "hi", [1, 2, 3], (4, 5), None]
for t in tests:
    print(f"  {describe(t)}")
type() results: type(42 ) = int type(3.14 ) = float type(True ) = bool type('hello' ) = str type(None ) = NoneType type([1, 2] ) = list type((1, 2) ) = tuple type({} ) = dict type(set() ) = set isinstance() with bool: isinstance(True, bool) : True isinstance(True, int) : True type(True) == int : False True is a bool 42 is an int 3.14 is a float 'hi' is a str of length 2 [1, 2, 3] is a sequence of 3 items (4, 5) is a sequence of 2 items value is None
Check bool before int

Because bool is a subclass of int, isinstance(True, int) returns True. If your code needs to distinguish booleans from integers, always check isinstance(x, bool) first in your condition chain, before isinstance(x, int). The order matters.

Type Coercion and Conversion

Python is strongly typed — it will not silently convert between types the way some languages do. "3" + 3 raises a TypeError, not a silent 6 or "33". Conversion must be explicit, using the type's constructor as a function.

# Python is strongly typed — no silent coercion
try:
    result = "3" + 3
except TypeError as e:
    print(f"'3' + 3 raises: {e}")

# Explicit conversion with constructor functions
print(f"\nint('42')        = {int('42')}")
print(f"int(3.9)         = {int(3.9)}")    # truncates, does NOT round
print(f"int('0b1010', 2) = {int('0b1010', 2)}")   # binary string -> int
print(f"int('ff', 16)    = {int('ff', 16)}")       # hex string -> int

print(f"\nfloat('3.14')    = {float('3.14')}")
print(f"float(7)         = {float(7)}")

print(f"\nstr(42)          = {str(42)!r}")
print(f"str(3.14)        = {str(3.14)!r}")
print(f"str(True)        = {str(True)!r}")

# List/tuple/set conversions — useful for deduplication and transformation
words = ("the", "quick", "brown", "fox", "the", "fox")
print(f"\ntuple -> set -> list (unique): {sorted(set(words))}")

# Converting input() — always returns str
# user_age = input("Age: ")   # returns "25" not 25
# age = int(user_age)         # explicit conversion required

# Implicit numeric coercion — the one place Python IS permissive
print(f"\nint + float  : {1 + 2.0}   type: {type(1 + 2.0).__name__}")
print(f"bool + int   : {True + 4}   type: {type(True + 4).__name__}")
print(f"bool + float : {True + 1.5} type: {type(True + 1.5).__name__}")

# round() vs int() — different behavior
print(f"\nround(3.5) = {round(3.5)}   int(3.5) = {int(3.5)}")
print(f"round(4.5) = {round(4.5)}   int(4.5) = {int(4.5)}")
# Python uses banker's rounding (round half to even)
'3' + 3 raises: can only concatenate str (not "int") to str int('42') = 42 int(3.9) = 3 int('0b1010', 2) = 10 int('ff', 16) = 255 float('3.14') = 3.14 float(7) = 7.0 str(42) = '42' str(3.14) = '3.14' str(True) = 'True' tuple -> set -> list (unique): ['brown', 'fox', 'quick', 'the'] int + float : 3.0 type: float bool + int : 5 type: int bool + float : 2.5 type: float round(3.5) = 4 int(3.5) = 3 round(4.5) = 4 int(4.5) = 4
int() truncates — it does not round

int(3.9) returns 3, not 4. It strips everything after the decimal point regardless of the fractional value. If you need rounding, use round(). Python also uses banker's rounding (round half to even), so round(3.5) and round(4.5) both return 4 — not what everyone expects.

Key Takeaways

  1. Every value is an object: Every value in Python has an identity, a type, and a value. id(), type(), and the value itself are always inspectable.
  2. int has no overflow: Python integers grow as large as your memory allows. You never need to worry about integer overflow the way you would in C or Java.
  3. Never use float for money: Use from decimal import Decimal and construct values from strings. Float's binary representation makes exact decimal arithmetic impossible.
  4. Mutable default arguments are a trap: Default argument values are evaluated once at function definition time. Using a list or dict as a default means all calls share the same object. Use None as the default and create a fresh container inside the function body.
  5. Use isinstance(), not type(): isinstance() respects inheritance. Check bool before int in any chain because bool is a subclass of int.
  6. Python is strongly typed — conversion must be explicit: "3" + 3 is always an error. Use int(), float(), str() etc. to convert. The one exception is numeric promotion: mixing int and float in arithmetic silently widens to float.
  7. Tuple vs list is a semantic choice: Tuples signal a fixed record with positional meaning. Lists signal an ordered collection that may grow or change. The mutability difference has real consequences for hashability and copying.

Data types are the foundation everything else in Python rests on. The bugs that come from ignoring mutability, misusing mutable defaults, or assuming == does the right thing on floats tend to be subtle and hard to trace. Getting these mechanics clear early saves a lot of debugging time later.