Part of Python for Beginners

Private Attributes, @property & Setters Explained - Python OOP Encapsulation Tutorial #21

Sandy LaneSandy Lane

Video: Private Attributes, @property & Setters Explained - Python OOP Encapsulation Tutorial #21 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Encapsulation: _private, __mangled, @property, setters

Python has no real private — just conventions. _name says "internal, don't touch." __name triggers name-mangling (prevents subclass collisions). @property turns method calls into attribute access. @x.setter adds validation on assignment.

Encapsulation hides implementation details. Python's approach is trust-based: convention over enforcement.

Public attributes (the default)

class Dog:
  def __init__(self, name, breed):
    self.name = name
    self.breed = breed

buddy = Dog("Buddy", "Golden Retriever")
print(buddy.name)
buddy.name = "Max"   # totally fine

By default, every attribute is public. Anyone can read or write.

For most data, this is fine. Don't hide things just because a Java tutorial said to.

Protected attributes: single underscore

class Employee:
  def __init__(self, name, salary):
    self.name = name
    self._salary = salary       # convention: internal

  def get_salary(self):
    return f"${self._salary:,}"

emp = Employee("Alice", 75000)
print(emp._salary)   # works — but you're not supposed to

_salary is a convention: "this is internal; don't access directly." Python doesn't enforce it. Linters and type checkers might warn.

The single underscore says "don't touch this from outside the class — the implementation may change."

Private attributes: double underscore (name mangling)

class BankAccount:
  def __init__(self, owner, balance):
    self.owner = owner
    self.__balance = balance     # name-mangled

  def get_balance(self):
    return f"${self.__balance:,.2f}"

acct = BankAccount("Bob", 5000)
print(acct.__balance)
# AttributeError: 'BankAccount' object has no attribute '__balance'

# But it's still accessible:
print(acct._BankAccount__balance)
# 5000 — Python mangled the name

Names starting with __ (but not ending with __) are name-mangled: __balance becomes _BankAccount__balance automatically. From outside, acct.__balance doesn't find anything.

This isn't security — it's collision-avoidance. The point is: if a subclass or external code defines __balance, it won't accidentally clash with BankAccount.__balance.

For most code: use _name (one underscore). __name is for the rare case where collisions would be a real problem.

@property: method as attribute

class Temperature:
  def __init__(self, celsius):
    self._celsius = celsius

  @property
  def celsius(self):
    return self._celsius

  @property
  def fahrenheit(self):
    return self._celsius * 9/5 + 32

temp = Temperature(25)
print(temp.celsius)      # 25 — looks like attribute access
print(temp.fahrenheit)   # 77.0 — but it's a method call

@property turns a method into a getter. Callers write temp.fahrenheit instead of temp.fahrenheit(). The method runs each time.

This lets you change implementation without breaking callers — start with a plain attribute, switch to a property when you need computation or validation.

Setter: @x.setter

class Temperature:
  def __init__(self, celsius):
    self._celsius = celsius

  @property
  def celsius(self):
    return self._celsius

  @celsius.setter
  def celsius(self, value):
    if value < -273.15:
      raise ValueError("Below absolute zero")
    self._celsius = value

temp = Temperature(25)
temp.celsius = 100      # calls the setter
temp.celsius = -300     # ValueError

@celsius.setter defines what happens on temp.celsius = .... Validate, transform, log — whatever.

The pattern:

@property
def x(self):           # getter
  return self._x

@x.setter
def x(self, value):    # setter
  # validate
  self._x = value

Note the convention: store the actual value in self._x (with underscore), expose it as self.x (no underscore).

Read-only property: no setter

class Circle:
  def __init__(self, radius):
    self._radius = radius

  @property
  def radius(self):
    return self._radius

  @radius.setter
  def radius(self, value):
    if value <= 0:
      raise ValueError("Radius must be positive")
    self._radius = value

  @property
  def area(self):
    return 3.14159 * self._radius ** 2

c = Circle(5)
print(c.area)
c.area = 100    # AttributeError: can't set attribute

area has only a getter — no setter, so it's read-only. Setting raises AttributeError.

This is perfect for derived values: area depends on radius; you can't set it directly.

Deleter

@x.deleter
def x(self):
  del self._x

del temp.x calls the deleter. Rare; mostly used to clean up resources.

Why @property over getter/setter methods?

Java-style:

class Temperature:
  def get_celsius(self):
    return self._celsius

  def set_celsius(self, value):
    self._celsius = value

# Caller:
temp.set_celsius(100)
print(temp.get_celsius())

Python-style:

@property
def celsius(self):
  return self._celsius

@celsius.setter
def celsius(self, value):
  self._celsius = value

# Caller:
temp.celsius = 100
print(temp.celsius)

Same behavior, cleaner call site. Plus you can start with a plain attribute and migrate to a property without touching callers — the holy grail of API stability.

Conventions summary

Name Meaning
name Public — part of the API
_name Protected — internal, don't touch
__name Private — name-mangled
__name__ Dunder — Python special method
name_ Trailing underscore to avoid keyword collision (class_, type_)

A bank account with full encapsulation

class BankAccount:
  def __init__(self, owner, initial_deposit):
    self.owner = owner
    self._balance = 0
    self._transactions = []
    self.deposit(initial_deposit)

  @property
  def balance(self):
    return self._balance

  @property
  def transaction_count(self):
    return len(self._transactions)

  def deposit(self, amount):
    if amount <= 0:
      raise ValueError("Deposit must be positive")
    self._balance += amount
    self._transactions.append(("deposit", amount))

  def withdraw(self, amount):
    if amount <= 0:
      raise ValueError("Withdrawal must be positive")
    if amount > self._balance:
      raise ValueError("Insufficient funds")
    self._balance -= amount
    self._transactions.append(("withdraw", amount))

balance is read-only (no setter). Modifications go through deposit/withdraw, which validate and log. The interface is small and safe.

Common stumbles

Confusing __name with __name__. Double underscore on both sides is a "dunder" (special method) — __init__, __str__, etc. Name-mangling only happens with leading-but-not-trailing __.

Setter without getter. @x.setter requires x to already be a property. Define the getter first.

Forgetting _x in property. self.x = value inside a setter for x recurses infinitely. Always use a different attribute name (self._x).

Treating _x as enforced private. Anyone can still access it. The convention is a hint, not a guard.

Heavy property with side effects. temp.fahrenheit looks instant — but if it does network IO, callers will be surprised. Make properties cheap, predictable, no side effects.

__name for "real" privacy. Name mangling isn't security — _BankAccount__balance is still accessible. Don't store secrets in attributes.

What's next

Lesson 22: abstract classes and operator overloading. abc.ABC, __add__, __eq__, __lt__, etc.

Recap

Python uses naming conventions, not enforcement: _name for "internal," __name for name-mangled (collision avoidance, not security). @property turns a method into attribute access; @x.setter adds an assignment hook. Use properties for validation, derived values, and migrating from plain attributes without breaking the API. Read-only = property without setter.

Next lesson: abstract classes and operator overloading.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.