Skip to content
aviral gupta

// I2.4 · ~30 min · Intermediate

Private names and properties

After this lesson you can mark internal attributes the Python way, predict what name mangling does to __name, and guard an attribute with a property and a validating setter.

Lesson 4 of 6 in I2 Classes

You will be able to

  • Mark internal attributes with one underscore, knowing that Python does not enforce privacy
  • Predict how name mangling rewrites __name inside a class, and why it exists
  • Write a @property with a setter that validates, and a read-only computed property
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lessons: nothing stops outside code from setting an attribute. What does this print?

    class Account:
        def __init__(self, balance):
            self.balance = balance
    
    
    acct = Account(100)
    acct.balance = -500
    print(acct.balance)
  2. Predict · Activity 2 of 7

    Predict before you read on: balance is now a property. What does this print?

    class Account:
        def __init__(self, balance):
            self.balance = balance
    
        @property
        def balance(self):
            return self._balance
    
        @balance.setter
        def balance(self, value):
            if value < 0:
                raise ValueError("balance must not be negative")
            self._balance = value
    
    
    acct = Account(100)
    try:
        acct.balance = -500
    except ValueError as err:
        print("refused:", err, "- balance", acct.balance)
  3. Practice · Activity 3 of 7

    Fill in the line so that Circle(2).area, without parentheses, gives the area.

    class Circle:
        def __init__(self, radius):
            self.radius = radius
    
        ____
        def area(self):
            return 3.14 * self.radius ** 2
  4. Practice · Activity 4 of 7

    vars(obj) shows the attributes stored on an object. What does this print?

    class Vault:
        def __init__(self):
            self.__pin = "1234"
    
    
    v = Vault()
    print([name for name in vars(v)])
  5. Practice · Activity 5 of 7

    Match each name, written inside a class, to what it means.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Base and Child both set self.__id. What does this print?

    class Base:
        def __init__(self):
            self.__id = "base"
    
        def base_id(self):
            return self.__id
    
    
    class Child(Base):
        def __init__(self):
            super().__init__()
            self.__id = "child"
    
        def child_id(self):
            return self.__id
    
    
    c = Child()
    print(c.base_id(), c.child_id())
  7. Apply · Activity 7 of 7

    Mini-task. Write a class Temperature with a celsius property. Its setter raises ValueError for values below -273.15; store the value in self._celsius. Add a read-only property fahrenheit, computed as celsius * 9 / 5 + 32. Temperature(25) gives 25 and 77.0; setting celsius to -300 is refused.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A product with guarded price and stock

price is a property with a validating setter, so even the price passed to __init__ is checked and rounded. stock is a read-only property: the only way to change it is restock() and sell(), which keep the count consistent. The last line reads lamp._stock directly, to show that the underscore is a convention and not a lock.

main.py

class Product:
    """Price is checked on every change; stock changes only through methods."""

    def __init__(self, name: str, price: float, stock: int = 0) -> None:
        self.name = name
        self.price = price  # goes through the setter below
        self._stock = stock

    @property
    def price(self) -> float:
        return self._price

    @price.setter
    def price(self, value: float) -> None:
        if value <= 0:
            raise ValueError(f"price must be positive, got {value}")
        self._price = round(value, 2)

    @property
    def stock(self) -> int:
        """Read-only: change it with restock() and sell()."""
        return self._stock

    def restock(self, amount: int) -> None:
        self._stock += amount

    def sell(self, amount: int) -> None:
        if amount > self._stock:
            raise ValueError(f"only {self._stock} {self.name} left")
        self._stock -= amount


lamp = Product("lamp", 24.999, stock=3)
print(lamp.price, lamp.stock)
lamp.sell(2)
lamp.restock(5)
print(lamp.stock)

try:
    lamp.price = -5
except ValueError as err:
    print("refused:", err)

try:
    lamp.stock = 99  # type: ignore[misc]
except AttributeError as err:
    print("refused:", err)

try:
    lamp.sell(10)
except ValueError as err:
    print("refused:", err)

print(lamp.price, lamp._stock)  # the underscore is a convention, not a lock

Run it with

python main.py

Output

25.0 3
6
refused: price must be positive, got -5
refused: property 'stock' of 'Product' object has no setter
refused: only 6 lamp left
25.0 6
  • 24.999 became 25.0: the setter rounds, and __init__ went through it.
  • Assigning lamp.stock fails because stock has no setter.
  • mypy also rejects the assignment to a read-only property; the type: ignore comment lets the demonstration run past it.
  • lamp._stock works from outside: nothing is enforced, the underscore only asks.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A checked temperature

Turn celsius into a property. Its setter raises ValueError for values below ABSOLUTE_ZERO and stores valid values in self._celsius; Temperature(-300) must fail too. Add a property fahrenheit (celsius * 9 / 5 + 32) with a setter that converts back and sets celsius, so the same check applies: setting fahrenheit to 212 makes celsius 100.0.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Write @property def celsius(self) that returns self._celsius, then @celsius.setter def celsius(self, value) that checks and stores.

  2. Hint 2

    Keep self.celsius = celsius in __init__: it now calls the setter, so Temperature(-300) is refused.

  3. Hint 3

    The fahrenheit setter converts and assigns self.celsius = (value - 32) * 5 / 9, which reuses the check.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class Temperature:
    ABSOLUTE_ZERO = -273.15

    def __init__(self, celsius: float) -> None:
        self.celsius = celsius

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < self.ABSOLUTE_ZERO:
            raise ValueError(f"{value} is below absolute zero")
        self._celsius = value

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

    @fahrenheit.setter
    def fahrenheit(self, value: float) -> None:
        self.celsius = (value - 32) * 5 / 9
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Temperature:
    ABSOLUTE_ZERO = -273.15

    def __init__(self, celsius: float) -> None:
        self.celsius = celsius

test_main.py

from main import Temperature


def test_reads_celsius():
    """celsius reads back the value given"""
    got = Temperature(21.5).celsius
    assert got == 21.5, f"Temperature(21.5).celsius is {got!r}"


def test_fahrenheit():
    """20 degrees Celsius is 68 degrees Fahrenheit"""
    got = getattr(Temperature(20), "fahrenheit", None)
    assert got == 68.0, f"Temperature(20).fahrenheit is {got!r}, expected 68.0"


def test_set_fahrenheit():
    """Setting fahrenheit to 212 makes celsius 100.0"""
    t = Temperature(0)
    t.fahrenheit = 212
    assert t.celsius == 100.0, f"after t.fahrenheit = 212, t.celsius is {t.celsius!r}"


def test_rejects_in_init():
    """Temperature(-300) raises ValueError"""
    try:
        Temperature(-300)
    except ValueError:
        pass
    else:
        assert False, "Temperature(-300) was accepted"


def test_rejects_and_keeps_value():
    """A refused value leaves the old one in place"""
    t = Temperature(10)
    try:
        t.celsius = -300
    except ValueError:
        pass
    else:
        assert False, "t.celsius = -300 was accepted"
    assert t.celsius == 10, f"after the refused value, t.celsius is {t.celsius!r}, expected 10"


def test_stored_privately():
    """The value is stored in _celsius"""
    t = Temperature(5)
    assert vars(t) == {"_celsius": 5}, f"the attributes on the object are {vars(t)!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A box with a secret code

Rewrite SecureBox. Store the code as self.__code, so it is mangled. Count wrong tries in self._attempts and show them through a read-only property attempts. Add a read-only property locked, True after MAX_ATTEMPTS wrong tries. open(code) returns True for the right code, but always False once locked. A wrong code adds one attempt.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    In __init__, write self.__code = code and self._attempts = 0. Inside the class, keep using self.__code.

  2. Hint 2

    A read-only property is a @property without a setter: @property def attempts(self) -> int: return self._attempts.

  3. Hint 3

    In open, check self.locked first. Return True for the right code; otherwise add one to self._attempts and return False.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

class SecureBox:
    MAX_ATTEMPTS = 3

    def __init__(self, code: str) -> None:
        self.__code = code
        self._attempts = 0

    @property
    def attempts(self) -> int:
        return self._attempts

    @property
    def locked(self) -> bool:
        return self._attempts >= self.MAX_ATTEMPTS

    def open(self, code: str) -> bool:
        if self.locked:
            return False
        if code == self.__code:
            return True
        self._attempts += 1
        return False
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class SecureBox:
    MAX_ATTEMPTS = 3

    def __init__(self, code: str) -> None:
        self.code = code
        self.attempts = 0

    def open(self, code: str) -> bool:
        return code == self.code

test_main.py

from main import SecureBox


def test_opens():
    """The right code opens the box"""
    assert SecureBox("4711").open("4711") is True, "open with the right code did not return True"


def test_counts_wrong_tries():
    """Wrong codes are counted"""
    box = SecureBox("4711")
    box.open("0000")
    box.open("1234")
    assert box.attempts == 2, f"after two wrong codes, attempts is {box.attempts!r}"


def test_locks():
    """After three wrong codes even the right one fails"""
    box = SecureBox("4711")
    for _ in range(3):
        box.open("0000")
    assert box.locked is True, "locked is not True after three wrong codes"
    assert box.open("4711") is False, "a locked box opened with the right code"


def test_attempts_read_only():
    """attempts cannot be reset from outside"""
    box = SecureBox("4711")
    try:
        box.attempts = 0  # type: ignore[misc]
    except AttributeError:
        pass
    else:
        assert False, "box.attempts = 0 was accepted; make attempts a property without a setter"


def test_code_is_mangled():
    """The code is stored as _SecureBox__code"""
    names = sorted(vars(SecureBox("4711")))
    assert names == ["_SecureBox__code", "_attempts"], f"the attributes on the object are {names!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Assigning the property inside its own setter

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

    @balance.setter
    def balance(self, value):
        self.balance = value


acct = Account()
acct.balance = 10

What Python prints

RecursionError: maximum recursion depth exceeded

Why, and the fix

self.balance = value inside the setter calls the setter again, which calls it again, until Python gives up. The property is the public face; the value needs its own storage under another name. Write self._balance = value in the setter, and return self._balance in the getter. Try this one in your own Python: in the browser, the endless recursion exhausts the page's stack.

Assigning to a read-only property

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

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


c = Circle(2)
c.area = 10

What Python prints

AttributeError: property 'area' of 'Circle' object has no setter

Why, and the fix

area has a getter only, so it cannot be assigned. That is usually what you want for a computed value: change the radius instead. If assigning really should work, add a method decorated with @area.setter that works out the new radius.

Reading a mangled name from outside

class Vault:
    def __init__(self):
        self.__pin = "1234"


print(Vault().__pin)

What Python prints

AttributeError: 'Vault' object has no attribute '__pin'

Why, and the fix

Only code inside the class body is rewritten, so outside the class __pin is looked up as it is written, and the object has no such attribute; it holds _Vault__pin. If outside code needs the value, provide a method or a read-only property. If it does not, this error is the point.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Privacy is a convention

Python has no private attributes. A leading underscore, as in self._balance, is a signal to other programmers: this is internal, it may change, do not rely on it from outside. Nothing stops code from reading or changing it, but well-behaved code respects the signal. Public attributes are normal in Python. Start with a plain attribute, and only when you need control, turn it into a property: callers keep writing obj.balance and never notice the change.

Name mangling for __name

Inside a class body, a name with two leading underscores and at most one trailing underscore, such as self.__pin, is rewritten to _ClassName__pin. From outside, obj.__pin raises AttributeError, but obj._Account__pin still works, so this is not security. It exists to avoid clashes: a subclass that uses its own __pin gets _Sub__pin, and the two never overwrite each other. Names with two underscores at both ends, like __init__, are not mangled. A single underscore is never mangled either.

Properties: attributes that run code

@property turns a method into an attribute: reading acct.balance calls the method, with no parentheses. Add a setter, a second method of the same name decorated with @balance.setter, and assigning acct.balance = -5 runs it, for example to reject bad values. The value itself lives in an underscore attribute such as self._balance; writing self.balance inside the setter would call the setter again. A property without a setter is read-only, and assigning to it raises AttributeError. If __init__ assigns self.balance, even the first value goes through the check.

Sources

Last reviewed September 29, 2026