Every value you've used so far — strings, lists, dicts — is an object: a bundle of data plus the functions (called methods when they belong to an object) that operate on it. "hello".upper() is calling a method that belongs to the string object "hello".
A class is a blueprint for creating your own objects, with your own data and your own methods.
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
return f"{self.name} says woof!"
__init__ is a special method Python calls automatically when you create a new object — it's where you set up the object's initial data (its attributes).
self is the object itself. Every method on a class takes self as its first parameter, and Python passes it automatically — you never pass it yourself when calling a method. Inside a method, self.name accesses (or sets) the name attribute on this particular object.
rex = Dog("Rex", 3) # calls __init__(self=rex, name="Rex", age=3)
rex.name # "Rex"
rex.bark() # "Rex says woof!"
fido = Dog("Fido", 5)
fido.name # "Fido" -- a completely separate object from rex
Each object created from a class has its own copy of the attributes set in __init__ — rex.name and fido.name don't interfere with each other, even though they came from the exact same class.
You could represent a dog as a dict ({"name": "Rex", "age": 3}) and a separate bark(dog) function. A class bundles the data and the behavior that operates on it into one thing, under one name — as your programs grow, that bundling makes it much easier to reason about "what can a Dog do" (look at the class) instead of hunting down every loose function that happens to take a dog-shaped dict.