SythraOpen app

Classes and Objects in Python

A class in Python is a blueprint for a new type; an object (instance) is what you get when you call the class. Objects store named attributes via dot notation, are passed by reference to functions, and require copy.deepcopy (not copy.copy) to fully duplicate when they contain embedded objects.

Sythra

11 min read

XLinkedIn
Classes and Objects in Python — cover illustration

So far, Python has been lending you its toys. Integers, strings, lists, dictionaries — all pre-built, ready to use, sitting in the box already. You just picked them up and played with them. This is the moment you get to build your own toy.

A class is a blueprint — a description of a new kind of thing you are inventing. An object is what you get when you actually build one from that blueprint. You can build as many objects from one class as you want, just like a cookie cutter can stamp out a hundred cookies from a single mould.

Real programs model real things. A game has players, enemies, and maps. A school app has students, teachers, and courses. None of those things exist as built-in Python types — you have to invent them, and that is exactly what classes let you do.

What you will learn

  • How to define your own class with its own attributes
  • How to create objects from that class and fill in their attributes
  • How embedded objects let one object contain another
  • Why passing objects to functions passes by reference, not by value
  • The difference between a shallow copy and a deep copy
  • Debugging tools: hasattr, type(), vars(), isinstance

User-defined types

Python's built-in types — int, float, str, list — are types that someone at Python headquarters invented and gave to you for free. A user-defined type is one that you invent. In Python, a user-defined type is called a class.

class Point:
    """Represents a point in 2-D space."""

That is it. class Point defines a new type and names it Point. The docstring — the triple-quoted string inside — explains what the class is for. It is not required, but it is very good practice, like a label on the tin.

When Python runs this, it creates a class object — a factory that can stamp out Point instances. Think of it as the cookie cutter, not the cookie:

print(Point)
# → <class '__main__.Point'>

To actually create a Point — to stamp out a cookie — you call the class like a function:

blank = Point()
print(blank)
# → <__main__.Point object at 0x7f3e9d3ac>

This is called instantiation. blank is now an instance of the Point class. That 0x... number is just a memory address — you can ignore it completely.

Attributes

A freshly created object is like a blank form — it exists, but has no information yet. You fill it in using dot notation:

blank.x = 3.0
blank.y = 4.0

These x and y are called attributes — named values that belong to the object. The dot means: "go to this object, and look up this name inside it." You read attributes back out the same way:

print(blank.y)    # 4.0

x = blank.x
print(x)          # 3.0

Notice the variable x and the attribute blank.x are completely separate — the attribute lives inside the object, while the variable lives in your regular program. They just happen to share a name — no conflict.

You can pass an object into a function just like any other value:

def print_point(p):
    print(f'({p.x}, {p.y})')

print_point(blank)    # (3.0, 4.0)

If you try to read an attribute that was never set, Python throws an AttributeError. Guard against this with the built-in hasattr function, which takes the object and the attribute name (as a string) and returns True or False:

p = Point()
hasattr(p, 'x')    # False — x has not been assigned yet
p.x = 3.0
hasattr(p, 'x')    # True

Embedded objects: rectangles

A Point was simple — just x and y. But an object's attribute can itself be another object. Let's define a Rectangle with a width, a height, and a corner — a Point object representing the lower-left corner:

class Rectangle:
    """Represents a rectangle.

    attributes: width, height, corner.
    """

box = Rectangle()
box.width = 100.0
box.height = 200.0
box.corner = Point()
box.corner.x = 0.0
box.corner.y = 0.0

box.corner is an attribute of box, and that attribute is itself a Point object. So box.corner.x means: "go to box, get corner, then get x from that corner." This is called an embedded object — an object stored inside another object. You can chain the dots as deep as you need.

Instances as return values

Functions can return objects — not just numbers and strings. Here is find_center, which takes a Rectangle and returns a brand new Point at its centre:

def find_center(rect):
    p = Point()
    p.x = rect.corner.x + rect.width / 2.0
    p.y = rect.corner.y + rect.height / 2.0
    return p

center = find_center(box)
print_point(center)    # (50.0, 100.0)

Every time find_center runs, it creates a fresh Point. The returned object belongs to whoever catches it — it is completely independent of the rectangle. Objects can also live inside lists, be stored in dictionaries, and be passed around, just like any other value.

Objects are mutable

You can change an object's attributes at any time — just assign a new value. You can also write functions that mutate objects — they go inside and change the original directly, without needing to return anything:

def grow_rectangle(rect, dwidth, dheight):
    rect.width += dwidth
    rect.height += dheight

print(box.width)     # 100.0
grow_rectangle(box, 50, 100)
print(box.width)     # 150.0

Inside grow_rectangle, rect is just another name for box — they point to the same object. This is the same aliasing behavior you saw with lists, now happening with objects.

There are two main approaches when writing functions that work on objects. Mutate in place modifies the original directly and returns nothing. Return a new object leaves the original untouched and returns a modified copy (typically via copy.deepcopy). Neither is always better — mutate-in-place is efficient but requires care about which variables share objects; return-new is safer and easier to reason about.

Copying: aliasing, shallow copy, and deep copy

Because objects are passed by reference, multiple variables can secretly point to the same object — this is called aliasing, and it can cause surprising bugs. The fix is copying — making a genuine independent duplicate. Python's copy module gives you two kinds.

A shallow copy (copy.copy()) copies the outer object but does not copy any objects embedded inside it:

import copy

box2 = copy.copy(box)

print(box2 is box)                 # False — different Rectangle objects
print(box2.corner is box.corner)   # True  — SAME Point object!

The Rectangle was copied, but corner on both rectangles still points to the same Point. That's the "shallow" in shallow copy — it only goes one level deep.

A deep copy (copy.deepcopy()) copies everything — the outer object and every embedded object, all the way down:

box3 = copy.deepcopy(box)

print(box3 is box)                 # False
print(box3.corner is box.corner)   # False — completely independent Point!

One more surprise about custom objects: p1 == p2 is False even when both points have identical x and y values, because Python's default == for objects checks identity (same object in memory), not equal contents. You fix this with a special method called __eq__ — you'll meet special methods properly in Classes and Methods.

Debugging tools for objects

  • hasattr(obj, 'name') — checks whether an attribute exists before you try to read it, avoiding a crash
  • type(obj) — tells you instantly what class an object belongs to
  • vars(obj) — returns all of an object's attributes as a dictionary in one shot
  • isinstance(obj, ClassName) — checks whether an object is the expected type, catching mistakes at the entry point of a function (called defensive programming)
def print_point(p):
    if not isinstance(p, Point):
        raise TypeError('Expected a Point, got ' + str(type(p)))
    print(f'({p.x}, {p.y})')

Common mistakes

  • Reading an attribute that was never assigned, causing an AttributeError
  • Assuming copy.copy() gives a fully independent object when it contains embedded objects — it doesn't
  • Expecting == to compare values on custom classes without defining __eq__
  • Forgetting that passing an object to a function shares the same object — mutating it inside the function changes the original too

Common questions

What is the difference between a class and an object in Python?

A class is a blueprint that defines a new type — it describes what attributes and behavior instances will have. An object (or instance) is the actual thing created by calling the class, like Point(). You can create many objects from one class.

What are attributes in a Python class?

Attributes are named values that belong to an object, set and accessed using dot notation, e.g. point.x = 3.0. They're stored per-instance, so different objects of the same class can have different attribute values.

Are objects passed by value or by reference in Python?

By reference. When you pass an object to a function, the function receives a direct link to the same object — not a private copy. If the function mutates the object's attributes, those changes persist after the function returns.

What is the difference between copy.copy() and copy.deepcopy()?

copy.copy() creates a shallow copy — it duplicates the outer object but leaves embedded objects shared between the original and the copy. copy.deepcopy() duplicates the outer object and every object embedded inside it, producing a completely independent copy.

Why does == return False for two objects with identical attribute values?

By default, Python's == for custom objects checks identity (whether they're the exact same object in memory), not whether their contents match. To compare by value, define a __eq__ method on your class.

Explore

Related topics

Keep going — these sit next to this concept in a real learning path.

Browse all python explainers →