Skip to content

Variables and Data Types

A variable in Python is just a name that points to something stored in memory. In some other programming languages, a variable acts like a box that holds a value. But in Python, it does not work that way. A variable does not hold the value itself - it just points to the object that holds the value.

Python uses something called dynamic typing. This means you do not need to tell Python what type of value a variable will hold. Python figures out the type on its own, based on whatever value you give it.

Key Point to Remember: In Python, variables are like labels pointing to objects, not boxes holding values. Once you understand this, the rest of Python’s memory system becomes much easier to follow.

In Python, everything is an object. And every object has three things attached to it:

  • Identity - a unique ID for that object (basically its memory address)
  • Type - what kind of object it is (like int, str, list, etc.)
  • Value - the actual data stored inside the object
x = 10
print(id(x)) # Identity (memory address)
print(type(x)) # Type (int)
print(x) # Value (10)

You create a variable simply by using the = sign to assign a value to it.

x = 10
name = "Sahil"
is_active = True

In the examples above:

  • x is pointing to an integer object
  • name is pointing to a string object
  • is_active is pointing to a boolean object
  • A variable is just a pointer (reference) to an object, not the object itself
  • Variables do not actually store the data - they just point to where the data lives in memory
  • More than one variable can point to the same object
  • A variable can be changed later to point to a completely different type of object

How Variables Actually Work Behind the Scenes

Section titled “How Variables Actually Work Behind the Scenes”

Python uses a system where variables only point to objects sitting somewhere in memory. They don’t hold the data themselves.

x = 10
y = x

Here is what happens step by step:

  • Python creates an integer object 10 in memory
  • x is now pointing to that object
  • y is also made to point to the same object that x is pointing to

So basically, both x and y are pointing to the exact same object right now.

x = 20
  • A brand new integer object 20 gets created in memory
  • x now points to this new object instead
  • y still points to the old object, which is 10
graph TD A[x] --> B[10] C[y] --> B

Python gives you two different ways to compare things:

a = [1, 2]
b = [1, 2]
a == b # True -> values are equal
a is b # False -> different objects in memory
  • == checks if the values are the same
  • is checks if it is literally the same object in memory

A data type tells Python what kind of value an object is holding, and it also decides what kind of operations you can do with that value.

int # Whole numbers (e.g., 10)
float # Decimal numbers (e.g., 3.14)
bool # True or False values
str # Text data (strings)
complex # Complex numbers (e.g., 1 + 2j)
NoneType # Means there is no value at all
list # A list of items that is ordered and can be changed
tuple # A list of items that is ordered but cannot be changed
set # A collection of unique items with no fixed order
frozenset # Same as set, but cannot be changed once created
dict # Stores data as key-value pairs

A string is basically a sequence of characters, and Python gives us many built-in methods to work with strings:

name = "Sahil"
print(name.upper()) # SAHIL
print(name.lower()) # sahil
print(name.capitalize()) # Sahil
print(name.replace("a", "o")) # Sohil
print(name.split("a")) # ['S', 'hil']
print(name.strip()) # Removes leading and trailing whitespace
print(name.startswith("S")) # True
print(name.endswith("l")) # True
print(name.find("h")) # 2 (index of first occurrence)

String Slicing:

[start:stop:step] - this lets you pick out a part of the string using index positions and a step value.

name = "Sahil"
print(name[0]) # S (first character)
print(name[1:4]) # hil (substring from index 1 to 3)
print(name[:3]) # Sah (first three characters)
print(name[3:]) # il (substring from index 3 to end)
print(name[-1]) # l (last character)
print(name[-3:]) # hil (last three characters)
print(name[::2]) # S a (every second character)

Lists are ordered collections that you can change anytime, and they come with several built-in methods:

my_list = [1, 2, 3]
my_list.append(4) # [1, 2, 3, 4]
my_list.insert(1, 10) # [1, 10, 2, 3, 4]
my_list.remove(2) # [1, 10, 3, 4
my_list.pop() # [1, 10, 3] (removes last element)
my_list.pop(1) # [1, 3] (removes element at index 1)
my_list.sort() # Sorts the list in place
my_list.reverse() # Reverses the list in place
my_list.clear() # Empties the list
my_list.extend([5, 6]) # [5, 6] (adds elements from another iterable)
my_list.count(3) # 1 (counts occurrences of 3)
my_list.index(5) # 0 (returns index of first occurrence of 5)
my_list.join(", ") # "5, 6" (joins list elements into a string)

List Slicing:

[start:stop:step] - this lets you pick out part of a list using index positions and a step value.

my_list = [1, 2, 3, 4, 5]
print(my_list[0]) # 1 (first element)
print(my_list[1:4]) # [2, 3, 4] (sublist from index 1 to 3)
print(my_list[:3]) # [1, 2, 3] (first three elements)
print(my_list[3:]) # [4, 5] (sublist from index 3 to end)
print(my_list[-1]) # 5 (last element)
print(my_list[-3:]) # [3, 4, 5] (last three elements)
print(my_list[::2]) # [1, 3, 5] (every second element)

Dictionaries store data as key-value pairs, and they don’t follow any particular order. Here are some useful built-in methods:

my_dict = {"name": "Sahil", "age": 20}
my_dict["name"] # "Sahil" (access value by key)
my_dict["age"] = 21 # Update value for key "age"
my_dict["city"] = "New York" # Add new key-value pair
del my_dict["age"] # Remove key-value pair with key "age"
my_dict.get("name") # "Sahil" (returns value for key, or None if key not found)
my_dict.keys() # dict_keys(['name', 'city']) (returns a view of keys)
my_dict.values() # dict_values(['Sahil', 'New York']) (returns a view of values)
my_dict.items() # dict_items([('name', 'Sahil'), ('city', 'New York')]) (returns a view of key-value pairs)
my_dict.clear() # Empties the dictionary

Sets hold unique values with no fixed order, and they have their own set of useful methods:

my_set = {1, 2, 3}
my_set.add(4) # {1, 2, 3, 4} (adds an element to the set)
my_set.remove(2) # {1, 3, 4} (removes an element from the set)
my_set.discard(5) # {1, 3, 4} (does not raise an error if element not found)
my_set.pop() # Removes and returns an arbitrary element from the set
my_set.clear() # Empties the set
my_set.union({5, 6}) # {1, 3, 4, 5, 6} (returns a new set with elements from both sets)
my_set.intersection({3, 4, 5}) # {3, 4} (returns a new set with elements common to both sets)
my_set.difference({3, 4, 5}) # {1} (returns a new set with elements in my_set but not in the other set)
my_set.symmetric_difference({3, 4, 5}) # {1, 5} (returns a new set with elements in either set but not in both)

A frozenset is just like a set, except you cannot change it once it’s made. It still has some useful methods:

my_frozenset = frozenset([1, 2, 3])
my_frozenset.union(frozenset([4, 5])) # frozenset({1, 2, 3, 4, 5}) (returns a new frozenset with elements from both sets)
my_frozenset.intersection(frozenset([2, 3, 4])) # frozenset({2, 3}) (returns a new frozenset with elements common to both sets)
my_frozenset.difference(frozenset([2, 3, 4])) # frozenset({1}) (returns a new frozenset with elements in my_frozenset but not in the other set)
my_frozenset.symmetric_difference(frozenset([2, 3, 4])) # frozenset({1, 4}) (returns a new frozenset with elements in either set but not in both)

Tuples are ordered collections that cannot be changed once created. They have a few simple methods:

my_tuple = (1, 2, 3)
my_tuple[0] # 1 (access element by index)
my_tuple.count(2) # 1 (counts occurrences of 2)
my_tuple.index(3) # 2 (returns index of first occurrence of 3)

Before you convert a value from one type to another, it’s a good idea to check its current type first, just to make sure the conversion will actually work.

x = "10"
if isinstance(x, str):
print("x is a string")

There are two common ways to check types in Python:

  • type() - tells you the exact type of the object
  • isinstance() - checks whether an object belongs to a certain class (or a subclass of it)
x = 10
print(type(x)) # <class 'int'>
print(isinstance(x, int)) # True

Type Casting (Converting One Type to Another)

Section titled “Type Casting (Converting One Type to Another)”

Type casting just means converting a value from one data type into another data type.

Implicit Type Conversion (Python Does It Automatically)

Section titled “Implicit Type Conversion (Python Does It Automatically)”

Sometimes Python will automatically convert one type into another, just so no data gets lost.

x = 10 # int
y = 3.5 # float
z = x + y # result is float (10.0 + 3.5 -> 13.5)

Explicit Type Conversion (You Do It Yourself)

Section titled “Explicit Type Conversion (You Do It Yourself)”

You can manually convert types using Python’s built-in functions.

int("10") # Converts string to integer
float("3.14") # Converts string to float
str(100) # Converts integer to string
bool(1) # Converts integer to boolean
int("abc") # Raises ValueError

Python lets you do different operations, but the types involved matter a lot. For example, when you do math between two different types, Python tries to convert them into one common type first.

x = 10 # int
y = 3.5 # float
result = x + y # result is float (10.0 + 3.5 -> 13.5)

If the types don’t match up and Python can’t convert them, it will raise a TypeError.

x = "10"
y = 5
result = x + y # Raises TypeError (cannot add str and int)
  • Arithmetic operations (e.g., +, -, *, /)
  • Comparison operations (e.g., ==, !=, <, >)
  • Logical operations (e.g., and, or, not)
  • Membership operations (e.g., in, not in)
  • Identity operations (e.g., is, is not)
  • Bitwise operations (e.g., &, |, ^, ~)
  • Assignment operations (e.g., =, +=, -=, *=, /=)
  • Operations between types that get along (like int and float) are allowed
  • Operations between types that don’t get along will raise a TypeError
  • Python automatically converts types when needed, just to avoid losing data

In Python, every value is treated as either “truthy” or “falsy” whenever it’s checked inside something like an if statement.

  • None
  • False
  • 0 (zero of any numeric type)
  • 0.0 (zero float)
  • 0j (zero complex)
  • '' (empty string)
  • [] (empty list)
  • () (empty tuple)
  • {} (empty dictionary)
  • set() (empty set)
  • Basically anything that is not falsy counts as truthy, such as:
  • Strings that have text in them
  • Numbers that are not zero
  • Collections that have something inside them (lists, tuples, sets, dictionaries)
  • Objects you’ve created from your own custom classes

Objects in Python are grouped into two types, depending on whether you can change them after they’re created or not.

An immutable object is an object whose value can never be changed after it is made. If you try to “change” it, Python actually just creates a brand new object instead.

x = 10
x = x + 1
  • A new integer object gets created here
  • The original object stays exactly the same as before

Examples:

  • int
  • float
  • str
  • tuple
lst = [1, 2]
lst.append(3)
  • This time, the same list object gets updated directly
  • The memory reference stays the same, nothing new gets created

Examples:

  • list
  • dict
  • set
FeatureMutable ObjectsImmutable Objects
ModifiableYesNo
Memory usageSame object gets updatedA new object gets created
Exampleslist, dict, setint, str, tuple

Python manages memory using two main things:

  • Heap memory - this is where all the actual objects are stored
  • References (names) - these are basically the variable names that point to those objects

So variables are really just names that are linked to objects sitting in memory.

graph TD A[Variable Name] --> B[Reference] B --> C[Heap Object]

Copying means making a new object that is based on an object you already have. How the copy behaves depends on whether the object can be changed (mutable) and whether you’re doing a shallow copy or a deep copy.

A shallow copy makes a new outer object, but the objects sitting inside it are still shared between the original and the copy.

import copy
a = [[1, 2], [3, 4]]
b = copy.copy(a)
b[0][0] = 100
print(a) # Original is affected

This diagram shows how a shallow copy works:

graph TD a --> L1[List] b --> L2[New List] L1 --> X L2 --> X
  • List b is a new list, but it still points to the same inner lists that a points to
  • So if you change an inner list through b, it also changes for a

A deep copy makes a totally separate and independent copy of the object, including everything nested inside it.

import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0][0] = 100
print(a) # Original is NOT affected

This diagram shows how a deep copy works:

graph TD a --> L1 b --> L2 L1 --> X1 L2 --> X2
  • List b is a new list with completely new inner lists, so changing b will never affect a
  • Deep copying uses more memory than shallow copying, but it makes sure the original and the copy are fully separate from each other
a = [1, 2]
b = a
  • No new object gets created here
  • Both a and b are pointing to the exact same object
  • If you change one, the other changes too
b = a.copy()
  • A new object actually gets created this time
  • But only the outer structure is copied (this is a shallow copy)
  • Anything nested inside might still be shared between them