Everything covered so far in this series has dealt with individual pieces of data — numbers, strings, lists, functions. python classes and objects are how you bundle data and the behavior that operates on it together into a single, reusable unit. This article covers python init self — the two things you'll type in nearly every class you write — along with the difference between instance and class attributes, and how to give your objects a readable string representation.
What Are Classes and Objects?
A class is a blueprint describing a set of attributes (data) and methods (behavior). An object is a specific instance created from that blueprint — an actual, concrete thing built according to the class's design.
A real-world analogy
Think of a class like a cookie cutter, and objects like the individual cookies it produces. Every cookie made with the same cutter shares the same basic shape — but each one is a distinct, independent cookie, and you could give each one different fillings or decorations without affecting the others, or the cutter itself.
A bare-bones example
class Dog:
pass
my_dog = Dog()
print(type(my_dog)) # <class '__main__.Dog'>Dog is the class — the blueprint, currently empty. my_dog is an object (also called an instance) created from that blueprint. Right now it doesn't do or hold anything, but it's a genuinely distinct object of type Dog.
The init() Method and self
What init() does
__init__() is a special method Python automatically runs immediately after a new object is created, and it's where you typically set up that object's starting attributes.
class Person:
def __init__(self, name, age):
self.name = name
self.age = ageA quick terminology note worth being precise about: __init__() is technically an initializer, not a constructor — the actual object-creation step happens in a different special method, __new__(), which you'll rarely need to touch directly. __init__() runs after the object already exists, setting up its initial state — for nearly all everyday Python code, __init__() is the one you'll actually write.
self, explained
self refers to the specific instance currently being worked with. Python passes it automatically as the first argument to every instance method — including __init__() — which is why you never explicitly pass it yourself when calling a method:
class Person:
def __init__(self, name, age):
self.name = name # 'self.name' sets an attribute on THIS specific instance
self.age = age
alex = Person("Alex", 30)When you write Person("Alex", 30), Python automatically fills in self as a reference to the new object being created — you only supply name and age yourself. Inside __init__(), self.name = name stores the value on that specific object, so it can be accessed later through that same object.
Creating multiple independent objects
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
alex = Person("Alex", 30)
sam = Person("Sam", 25)
print(alex.name, alex.age) # Alex 30
print(sam.name, sam.age) # Sam 25alex and sam are two entirely separate objects, each with its own independent name and age — changing one has no effect on the other whatsoever, exactly like two different cookies from the same cutter.
Instance Attributes vs. Class Attributes
Instance attributes
These are assigned via self.attribute_name inside __init__() (or any other method), and they're unique to each individual object:
class Person:
def __init__(self, name, age):
self.name = name # instance attribute
self.age = age # instance attributeClass attributes
These are defined directly in the class body, outside any method, and they're shared across all instances of that class, unless a specific instance overrides its own copy:
class Person:
species = "Homo sapiens" # class attribute — shared by every Person
def __init__(self, name, age):
self.name = name
self.age = age
alex = Person("Alex", 30)
sam = Person("Sam", 25)
print(alex.species) # Homo sapiens
print(sam.species) # Homo sapiens — same value, shared from the class itselfIf you assign directly to alex.species = "...", that creates a new instance attribute on alex specifically, which then shadows (takes priority over) the shared class attribute for that one object — but sam.species remains unaffected, still reading from the shared class-level value.
The mutable class attribute gotcha
This is genuinely important, and it catches people off guard regularly: if a class attribute is a mutable object — a list or dictionary — every instance shares the exact same object, not independent copies. Modifying it through one instance affects every other instance too:
class ShoppingCart:
items = [] # DANGER — a mutable class attribute, shared by every instance
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart2.items) # ['apple'] — cart2 sees cart1's item too!This is the exact same underlying issue as the mutable default argument trap covered in the earlier function arguments article — a single shared mutable object, unintentionally affecting multiple places that were supposed to be independent. The fix is the same general principle: initialize mutable attributes inside __init__(), as instance attributes, so each object genuinely gets its own separate list:
class ShoppingCart:
def __init__(self):
self.items = [] # instance attribute — a fresh, independent list per object
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart1.items) # ['apple']
print(cart2.items) # [] — correctly independent nowMethods and the str Special Method
Regular instance methods
A method is just a function defined inside a class, and — like __init__() — it automatically receives self as its first argument, giving it access to that specific object's attributes:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def have_birthday(self):
self.age += 1
print(f"{self.name} is now {self.age}")
alex = Person("Alex", 30)
alex.have_birthday() # Alex is now 31have_birthday() reads and modifies self.age, operating specifically on whichever object it was called on — alex.have_birthday() only affects alex, never any other Person instance.
The default printed representation isn't useful
Try printing an object without any special setup, and you get something genuinely unhelpful:
alex = Person("Alex", 30)
print(alex)
# <__main__.Person object at 0x7f8b1c0a5d90>That output tells you almost nothing useful — just the class name and a memory address.
str(): a readable string representation
Defining a __str__() method lets you control exactly what gets shown when an object is printed, or passed to str():
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"{self.name}, age {self.age}"
alex = Person("Alex", 30)
print(alex) # Alex, age 30 — much more usefulYou didn't call __str__() directly anywhere — print() calls it automatically behind the scenes, whenever it needs a string representation of an object.
A preview: dunder methods
__init__() and __str__() are both examples of what are commonly called "dunder" methods (short for "double underscore," referring to their __name__ naming pattern) — special methods that Python calls automatically for specific operations, rather than something you call directly yourself. There are many more of these, covering things like equality comparison (__eq__), addition (__add__), and length (__len__) — a topic covered in more depth in a later article in this series on operator overloading and magic methods.
Putting It Together: A Complete Example
Here's a fuller example combining everything covered in this article — __init__(), instance attributes, a class attribute, a regular method, and __str__():
class BankAccount:
bank_name = "Python National Bank" # class attribute — shared by every account
def __init__(self, owner, balance=0):
self.owner = owner # instance attribute
self.balance = balance # instance attribute
def deposit(self, amount):
self.balance += amount
print(f"Deposited ")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds")
else:
self.balance -= amount
print(f"Withdrew ")
def __str__(self):
return f"{self.owner}'s account at {self.bank_name} — Balance: "
account1 = BankAccount("Alex", 100)
account2 = BankAccount("Sam", 50)
account1.deposit(50)
account2.withdraw(20)
print(account1) # Alex's account at Python National Bank — Balance: $150
print(account2) # Sam's account at Python National Bank — Balance: $30Notice that account1 and account2 maintain entirely independent balances (instance attributes), while both correctly share the same bank_name (a class attribute) — exactly the distinction covered in Section 3, now working together in a single, realistic example.
What's next
This article covers the foundation — defining a class, creating objects from it, and giving those objects both data and behavior. It sets up the concepts covered in later articles in this series: inheritance (building new classes based on existing ones), encapsulation (controlling access to an object's internals), and the dunder methods that let your own classes integrate naturally with Python's built-in operators and functions.