Skip to main content

Python Objects

OOP Core

Working with Objects

Every class instantiates objects. Understand identity, equality, and lifecycle events for predictable behavior.

Everything is an object

In Python this is literal, not a slogan. Numbers, strings, functions — even classes themselves — are objects with a type and an identity:

n = 42
print(type(n)) # -> <class 'int'>
print(id(n) > 0) # -> True — every object has an identity

def greet():
return 'hi'

print(type(greet)) # -> <class 'function'>
print(type(len)) # -> <class 'builtin_function_or_method'>

print(type(int)) # -> <class 'type'> — classes are objects too
print(isinstance(int, object)) # -> True

Because functions are objects, you can pass them as arguments, store them in lists, and attach attributes to them. Because classes are objects, you can pass a class to a function or keep one in a dict. The whole language is built on this uniformity — the data types you use daily are all instances of some class.

Identity vs equality

Identity asks "is this the same object?" (is, compares id()); equality asks "does it hold the same value?" (==, defined by __eq__). Two instances built from the same arguments are equal-looking but not identical:

class Invoice:
def __init__(self, total, customer):
self.total = total
self.customer = customer

a = Invoice(100, "Ada")
b = Invoice(100, "Ada")
print(a is b) # False
print(a == b) # Depends on __eq__ — False here, default falls back to identity

c = a
print(a is c) # -> True — two names, one object

Define __eq__ to give == a real meaning for your type:

class Invoice:
def __init__(self, total, customer):
self.total = total
self.customer = customer

def __eq__(self, other):
return (self.total, self.customer) == (other.total, other.customer)

print(Invoice(100, "Ada") == Invoice(100, "Ada")) # -> True

Use is only for singletons like None; use == for values. The full story, including the small-integer caveat, is in identity vs equality. And remember mutable objects share references — two names bound to one list see each other's changes; copy the list when you need independence.

Discovering attributes with dir()

Every object carries its attributes and methods with it, and dir() lists them — handy in the REPL when exploring an unfamiliar object:

s = 'hello'
methods = [name for name in dir(s) if not name.startswith('_')]
print(methods[:5]) # -> ['capitalize', 'casefold', 'center', 'count', 'encode']

print(hasattr(s, 'upper')) # -> True
print(getattr(s, 'upper')()) # -> HELLO — methods are attributes you can call

Lifecycle

  1. __new__ allocates memory (rarely overridden).
  2. __init__ initializes attributes.
  3. __repr__ / __str__ render human-readable info.
  4. __del__ runs when garbage collected — timing is non-deterministic, so avoid relying on it for cleanup. Use context managers (with statements) instead.

CPython keeps objects alive by reference counting: each object tracks how many names and containers point at it. sys.getrefcount shows the count (one higher than expected, because passing the object as an argument adds a temporary reference):

import sys

data = []
print(sys.getrefcount(data)) # -> 2 — the name data + the argument reference

alias = data
print(sys.getrefcount(data)) # -> 3

del alias
print(sys.getrefcount(data)) # -> 2

del removes a name, decrementing the count — it does not directly destroy the object. When the count hits zero, CPython reclaims the object immediately (a separate garbage collector handles reference cycles):

class Resource:
def __del__(self):
print('finalized')

r = Resource()
del r # last reference gone, count hits zero
# finalized

That immediacy is a CPython detail, not a language guarantee — which is exactly why the advice above stands: do real cleanup in a context manager, not in __del__.

Make objects debuggable with repr

The default representation is nearly useless: <__main__.Point object at 0x7f3a...>. Define __repr__ to say what the object is; print and error messages pick it up automatically:

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

def __repr__(self):
return f'Point({self.x}, {self.y})'

p = Point(2, 3)
print(p) # -> Point(2, 3) — str falls back to __repr__
print([p]) # -> [Point(2, 3)] — containers always use __repr__

Add __str__ only when you want a second, user-facing rendering distinct from the debugging one. Both belong to the wider family of dunder methods.

Frequently Asked Questions

What does "everything is an object" actually mean?

Every value in Python — numbers, strings, functions, modules, classes — is a full object with a type (type(x)), an identity (id(x)), and attributes. There are no primitive non-object values as in Java or C. Practically it means anything can be assigned to a variable, passed to a function, stored in a container, or inspected with dir().

When is an object destroyed?

When nothing references it anymore. CPython counts references and reclaims an object the moment its count reaches zero; a cycle-detecting garbage collector handles objects that reference each other. del only removes one reference (a name) — the object survives as long as any other reference exists. Never depend on the exact moment of destruction for cleanup; use a with statement.

x = [1, 2]
y = x
del x # the list lives on — y still references it
print(y) # -> [1, 2]

Why does is sometimes work for small numbers?

CPython caches the integers -5 through 256 and reuses (interns) some strings, so two names for the value 100 can point at the very same cached object and 100 is 100 happens to be True. This is an implementation optimization, not a promise — it fails for larger numbers and varies between contexts. Always compare values with ==; reserve is for None.

x = 1000
y = 10 * 100
print(x == y) # -> True — always correct
print(x is y) # implementation-dependent: may be True or False

Next up in your learning path