Skip to content
Python Basics: Validating Attribute Updates with Properties

Python Basics: Validating Attribute Updates with Properties

October 19, 2026

A property keeps attribute-style access while running a getter or setter. Route initialization through the setter so new objects and later updates follow the same validation rules.

These three standalone examples were tested on Linux with Python 3.12.14. They require no third-party packages. Assertions check the results; run without Python’s -O option.

Prepare a scratch directory

You need Bash, Python 3.12 or newer on your PATH, a text editor, and the mktemp, rm, and rmdir utilities. Keep using the same terminal and save each named source file as UTF-8.

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

1. Validate an integer range

Save as validate_retry_count.py:

class RetryPolicy:
    def __init__(self, retries):
        self.retries = retries

    @property
    def retries(self):
        return self._retries

    @retries.setter
    def retries(self, value):
        if type(value) is not int:
            raise TypeError("retries must be an integer")
        if not 0 <= value <= 5:
            raise ValueError("retries must be between 0 and 5")
        self._retries = value


policy = RetryPolicy(2)
policy.retries = 4
assert policy.retries == 4
print(policy.retries)
for invalid in (-1, True):
    try:
        policy.retries = invalid
    except (TypeError, ValueError) as error:
        print(type(error).__name__)
    else:
        raise AssertionError("Invalid value accepted")
assert policy.retries == 4

Run it:

python3 validate_retry_count.py

Output:

4
ValueError
TypeError

Validate before updating the backing attribute so failed assignments leave the old value intact. The exact-type check intentionally rejects booleans and int subclasses. Using self.retries inside its own setter would recurse.

2. Normalize text before storing it

Save as normalize_display_name.py:

class Profile:
    def __init__(self, name):
        self.name = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("name must be text")
        cleaned = value.strip()
        if not cleaned:
            raise ValueError("name must not be blank")
        self._name = cleaned


profile = Profile("  Ada  ")
assert profile.name == "Ada"
print(profile.name)
try:
    profile.name = "   "
except ValueError:
    print("Blank name rejected")
else:
    raise AssertionError("Expected ValueError")
assert profile.name == "Ada"

Run it:

python3 normalize_display_name.py

Output:

Ada
Blank name rejected

The setter checks the input type, trims surrounding whitespace, and rejects an empty result. Normalization is part of this interface: callers read back the cleaned value rather than the exact original string.

3. Expose a read-only computed value

Save as read_computed_total.py:

class Basket:
    def __init__(self, prices):
        self._prices = tuple(prices)

    @property
    def total(self):
        return sum(self._prices)


basket = Basket([120, 80, 50])
assert basket.total == 250
print("Total cents:", basket.total)
try:
    basket.total = 1
except AttributeError:
    print("Total is read-only")
else:
    raise AssertionError("Expected AttributeError")

Run it:

python3 read_computed_total.py

Output:

Total cents: 250
Total is read-only

A property without a setter rejects ordinary assignment. This example assumes integer prices in cents and recomputes the total when read; property does not cache the result.

When to use this

Keep setters predictable and avoid surprising network or file operations. A leading underscore marks an internal attribute by convention; it does not prevent someone from changing it directly. Properties help maintain an interface, not enforce a security boundary.

References

Clean up

In the same terminal, remove only these example files, then return to your original directory and remove the empty scratch directory:

rm -- validate_retry_count.py normalize_display_name.py read_computed_total.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on